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

- 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>
This commit is contained in:
MostlyKandDanilo Leal authored and GitHub committed 2026-02-04 07:05:22 -06:00
1 parent 78690f8b33
commit 81a90ba6fc
9 files changed
+237 -38

No files matched your search

Generated
+1
View File
@@ -13863,6 +13863,7 @@ dependencies = [
"util",
"uuid",
"workspace",
"zed_actions",
]
[[package]]
+4
View File
@@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.18066 11.3784C8.09052 11.3828 7.98455 11.3854 7.87814 11.3854C6.11134 11.3854 4.51339 10.6629 3.36352 9.49681L3.36264 9.49593C3.72585 10.5249 4.374 11.3779 5.21255 11.9843L5.2275 11.9949C6.04714 12.5889 7.07301 12.9455 8.18198 12.9455C9.29096 12.9455 10.3164 12.5894 11.1505 11.9852L11.1356 11.9953C11.9886 11.3784 12.6368 10.5258 12.989 9.53287L13 9.49681C11.8484 10.6634 10.2491 11.3859 8.48143 11.3859C8.37546 11.3859 8.26993 11.3832 8.16527 11.3779L8.18066 11.3784ZM8.18022 4.07285C8.27081 4.06801 8.37678 4.06538 8.48363 4.06538C10.25 4.06538 11.8479 4.78784 12.9974 5.95354L12.9982 5.95442C12.635 4.92547 11.9869 4.07241 11.1488 3.46604L11.1338 3.45548C10.3142 2.86186 9.28832 2.50613 8.17935 2.50613C7.07037 2.50613 6.04494 2.86186 5.21035 3.46604L5.2253 3.45592C4.3718 4.07241 3.72365 4.92547 3.37144 5.91836L3.36044 5.95442C4.51339 4.78828 6.11354 4.06582 7.88253 4.06582C7.98719 4.06582 8.09184 4.06845 8.19518 4.07329L8.18022 4.07285ZM4.1493 3.23914C4.1493 3.39876 4.08466 3.54343 3.98045 3.64852C3.87668 3.75318 3.73289 3.81825 3.57415 3.81825C3.41541 3.81825 3.27118 3.75318 3.16785 3.64852C3.06363 3.54343 2.99899 3.39832 2.99899 3.2387C2.99899 3.07908 3.06363 2.93397 3.16785 2.82888C3.27162 2.72423 3.41541 2.65915 3.57415 2.65915C3.73289 2.65915 3.87712 2.72423 3.98089 2.82932C4.0851 2.93441 4.1493 3.07908 4.1493 3.23914ZM12.7159 2.19041C12.7159 2.19085 12.7159 2.19129 12.7159 2.19129C12.7159 2.40719 12.6289 2.60286 12.4882 2.74489C12.3479 2.88692 12.1531 2.97487 11.9381 2.97487C11.7231 2.97487 11.5283 2.88692 11.388 2.74533C11.2473 2.60287 11.1602 2.40675 11.1602 2.19041C11.1602 1.97406 11.2473 1.77795 11.388 1.63548C11.5283 1.49389 11.7226 1.40594 11.9381 1.40594C12.1535 1.40594 12.3479 1.49389 12.4882 1.63548C12.6289 1.77751 12.7159 1.97318 12.7159 2.18953V2.19041ZM5.33875 13.6056C5.33875 13.6056 5.33875 13.606 5.33875 13.6064C5.33875 13.8786 5.22926 14.1253 5.05161 14.3047C4.87484 14.4837 4.62948 14.5945 4.35861 14.5945C4.08774 14.5945 3.84238 14.4837 3.66561 14.3052C3.48796 14.1258 3.37847 13.8786 3.37847 13.606C3.37847 13.3334 3.48796 13.0867 3.66561 12.9068C3.84238 12.7279 4.0873 12.6175 4.35861 12.6175C4.62992 12.6175 4.87484 12.7283 5.05161 12.9073C5.22882 13.0867 5.33875 13.3334 5.33875 13.6056C5.33875 13.606 5.33875 13.606 5.33875 13.6064V13.6056Z" fill="#C6CAD0"/>
<path d="M4.1493 3.23914C4.1493 3.39876 4.08466 3.54343 3.98045 3.64852C3.87668 3.75318 3.73289 3.81825 3.57415 3.81825C3.41541 3.81825 3.27118 3.75318 3.16785 3.64852C3.06363 3.54343 2.99899 3.39832 2.99899 3.2387C2.99899 3.07908 3.06363 2.93397 3.16785 2.82888C3.27162 2.72423 3.41541 2.65915 3.57415 2.65915C3.73289 2.65915 3.87712 2.72423 3.98089 2.82932C4.0851 2.93441 4.1493 3.07908 4.1493 3.23914ZM4.1493 3.23914V3.23958M5.33875 13.6056C5.33875 13.6056 5.33875 13.606 5.33875 13.6064M5.33875 13.6056C5.33875 13.3334 5.22882 13.0867 5.05161 12.9073C4.87484 12.7283 4.62992 12.6175 4.35861 12.6175C4.0873 12.6175 3.84238 12.7279 3.66561 12.9068C3.48796 13.0867 3.37847 13.3334 3.37847 13.606C3.37847 13.8786 3.48796 14.1258 3.66561 14.3052C3.84238 14.4837 4.08774 14.5945 4.35861 14.5945C4.62948 14.5945 4.87484 14.4837 5.05161 14.3047C5.22926 14.1253 5.33875 13.8786 5.33875 13.6064M5.33875 13.6056C5.33875 13.606 5.33875 13.606 5.33875 13.6064M5.33875 13.6056V13.6064M8.18066 11.3784C8.09052 11.3828 7.98455 11.3854 7.87814 11.3854C6.11134 11.3854 4.51339 10.6629 3.36352 9.49681L3.36264 9.49593C3.72585 10.5249 4.374 11.3779 5.21255 11.9843L5.2275 11.9949C6.04714 12.5889 7.07301 12.9455 8.18198 12.9455C9.29096 12.9455 10.3164 12.5894 11.1505 11.9852L11.1356 11.9953C11.9886 11.3784 12.6368 10.5258 12.989 9.53287L13 9.49681C11.8484 10.6634 10.2491 11.3859 8.48143 11.3859C8.37546 11.3859 8.26993 11.3832 8.16527 11.3779L8.18066 11.3784ZM8.18022 4.07285C8.27081 4.06801 8.37678 4.06538 8.48363 4.06538C10.25 4.06538 11.8479 4.78784 12.9974 5.95354L12.9982 5.95442C12.635 4.92547 11.9869 4.07241 11.1488 3.46604L11.1338 3.45548C10.3142 2.86186 9.28832 2.50613 8.17935 2.50613C7.07037 2.50613 6.04494 2.86186 5.21035 3.46604L5.2253 3.45592C4.3718 4.07241 3.72365 4.92547 3.37144 5.91836L3.36044 5.95442C4.51339 4.78828 6.11354 4.06582 7.88253 4.06582C7.98719 4.06582 8.09184 4.06845 8.19517 4.07329L8.18022 4.07285ZM12.7159 2.19041C12.7159 2.19085 12.7159 2.19129 12.7159 2.19129C12.7159 2.40719 12.6289 2.60287 12.4882 2.74489C12.3479 2.88692 12.1531 2.97487 11.9381 2.97487C11.7231 2.97487 11.5283 2.88692 11.388 2.74533C11.2473 2.60287 11.1602 2.40675 11.1602 2.19041C11.1602 1.97406 11.2473 1.77795 11.388 1.63548C11.5283 1.49389 11.7226 1.40594 11.9381 1.40594C12.1535 1.40594 12.3479 1.49389 12.4882 1.63548C12.6289 1.77751 12.7159 1.97318 12.7159 2.18953V2.19041Z" stroke="#C6CAD0" stroke-width="0.25"/>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

+14
View File
@@ -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": {
+14
View File
@@ -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": {
+14
View File
@@ -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": {
+1
View File
@@ -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"] }
+12
View File
@@ -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,
+175 -38
View File
@@ -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(),
}
} 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 file_content = serde_json::to_string(&json)?;
};
let notebook = nbformat::parse_notebook(&file_content);
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
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
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) {
+2
View File
@@ -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"),