Cargo.lock 🔗
@@ -13863,6 +13863,7 @@ dependencies = [
"util",
"uuid",
"workspace",
+ "zed_actions",
]
[[package]]
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>
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(-)
@@ -13863,6 +13863,7 @@ dependencies = [
"util",
"uuid",
"workspace",
+ "zed_actions",
]
[[package]]
@@ -0,0 +1,4 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -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": {
@@ -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": {
@@ -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": {
@@ -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"] }
@@ -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,
@@ -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(¬ebook_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) {
@@ -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"),