Skip to repository content2212 lines · 82.3 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:57:16.875Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
notebook_ui.rs
1#![allow(unused, dead_code)]
2use std::future::Future;
3use std::{path::PathBuf, sync::Arc};
4
5use anyhow::{Context as _, Result};
6use client::proto::ViewId;
7use collections::HashMap;
8use editor::DisplayPoint;
9use feature_flags::{FeatureFlagAppExt as _, NotebookFeatureFlag};
10use futures::FutureExt;
11use futures::future::Shared;
12use gpui::{
13 AnyElement, App, Entity, EventEmitter, FocusHandle, Focusable, KeyContext, ListScrollEvent,
14 ListState, Point, Task, TaskExt, actions, list, prelude::*,
15};
16use jupyter_protocol::JupyterKernelspec;
17use language::{Language, LanguageRegistry};
18use log;
19use project::{Project, ProjectEntryId, ProjectPath};
20use settings::Settings as _;
21use ui::{CommonAnimationExt, KeyBinding, Tooltip, prelude::*};
22use workspace::item::{ItemEvent, SaveOptions, TabContentParams};
23use workspace::searchable::SearchableItemHandle;
24use workspace::{Item, ItemHandle, Pane, ProjectItem, ToolbarItemLocation};
25
26use super::{Cell, CellEvent, CellPosition, MarkdownCellEvent, RenderableCell};
27
28use nbformat::v4::CellId;
29use nbformat::v4::Metadata as NotebookMetadata;
30use serde_json;
31use uuid::Uuid;
32
33use crate::components::{KernelPickerDelegate, KernelSelector};
34use crate::kernels::{
35 Kernel, KernelSession, KernelSpecification, KernelStatus, LocalKernelSpecification,
36 NativeRunningKernel, RemoteRunningKernel, SshRunningKernel, WslRunningKernel,
37};
38use crate::notebook::MovementDirection;
39use crate::repl_store::ReplStore;
40
41use picker::Picker;
42use runtimelib::{ExecuteRequest, JupyterMessage, JupyterMessageContent};
43use ui::PopoverMenuHandle;
44use zed_actions::editor::{MoveDown, MoveUp};
45use zed_actions::notebook::{
46 AddCodeBlock, AddMarkdownBlock, ClearOutputs, EnterCommandMode, EnterEditMode, InterruptKernel,
47 MoveCellDown, MoveCellUp, NotebookMoveDown, NotebookMoveUp, OpenNotebook, RestartKernel, Run,
48 RunAll, RunAndAdvance,
49};
50
51/// Whether the notebook is in command mode (navigating cells) or edit mode (editing a cell).
52#[derive(Clone, Copy, PartialEq, Eq)]
53pub(crate) enum NotebookMode {
54 Command,
55 Edit,
56}
57
58#[derive(PartialEq, Eq)]
59enum SelectionMode {
60 SelectOnly,
61 SelectAndMove,
62}
63
64pub(crate) const MAX_TEXT_BLOCK_WIDTH: f32 = 9999.0;
65pub(crate) const SMALL_SPACING_SIZE: f32 = 8.0;
66pub(crate) const MEDIUM_SPACING_SIZE: f32 = 12.0;
67pub(crate) const LARGE_SPACING_SIZE: f32 = 16.0;
68pub(crate) const GUTTER_WIDTH: f32 = 19.0;
69pub(crate) const CODE_BLOCK_INSET: f32 = MEDIUM_SPACING_SIZE;
70pub(crate) const CONTROL_SIZE: f32 = 20.0;
71
72const NOTEBOOK_EXTENSION: &str = "ipynb";
73
74pub fn init(cx: &mut App) {
75 if cx.has_flag::<NotebookFeatureFlag>() || std::env::var("LOCAL_NOTEBOOK_DEV").is_ok() {
76 workspace::register_project_item::<NotebookEditor>(cx);
77 }
78
79 cx.observe_flag::<NotebookFeatureFlag, _>({
80 move |flag, cx| {
81 if *flag {
82 workspace::register_project_item::<NotebookEditor>(cx);
83 } else {
84 // todo: there is no way to unregister a project item, so if the feature flag
85 // gets turned off they need to restart Zed.
86 }
87 }
88 })
89 .detach();
90}
91
92pub struct NotebookEditor {
93 languages: Arc<LanguageRegistry>,
94 project: Entity<Project>,
95 worktree_id: project::WorktreeId,
96 focus_handle: FocusHandle,
97 notebook_item: Entity<NotebookItem>,
98 notebook_language: Shared<Task<Option<Arc<Language>>>>,
99 remote_id: Option<ViewId>,
100 cell_list: ListState,
101 notebook_mode: NotebookMode,
102 selected_cell_index: usize,
103 cell_order: Vec<CellId>,
104 original_cell_order: Vec<CellId>,
105 cell_map: HashMap<CellId, Cell>,
106 kernel: Kernel,
107 kernel_specification: Option<KernelSpecification>,
108 execution_requests: HashMap<String, CellId>,
109 kernel_picker_handle: PopoverMenuHandle<Picker<KernelPickerDelegate>>,
110}
111
112impl NotebookEditor {
113 pub fn new(
114 project: Entity<Project>,
115 notebook_item: Entity<NotebookItem>,
116 window: &mut Window,
117 cx: &mut Context<Self>,
118 ) -> Self {
119 let focus_handle = cx.focus_handle();
120
121 let languages = project.read(cx).languages().clone();
122 let language_name = notebook_item.read(cx).language_name();
123 let worktree_id = notebook_item.read(cx).project_path.worktree_id;
124
125 let notebook_language = notebook_item.read(cx).notebook_language();
126 let notebook_language = cx
127 .spawn_in(window, async move |_, _| notebook_language.await)
128 .shared();
129
130 let mut cell_order = vec![]; // Vec<CellId>
131 let mut cell_map = HashMap::default(); // HashMap<CellId, Cell>
132
133 let cell_count = notebook_item.read(cx).notebook.cells.len();
134 for index in 0..cell_count {
135 let cell = notebook_item.read(cx).notebook.cells[index].clone();
136 let cell_id = cell.id();
137 cell_order.push(cell_id.clone());
138 let cell_entity = Cell::load(&cell, &languages, notebook_language.clone(), window, cx);
139
140 match &cell_entity {
141 Cell::Code(code_cell) => {
142 let cell_id_for_focus = cell_id.clone();
143 cx.subscribe_in(code_cell, window, move |this, _cell, event, window, cx| {
144 match event {
145 CellEvent::Run(cell_id) => {
146 this.execute_cell(cell_id.clone(), window, cx)
147 }
148 CellEvent::FocusedIn(_) => {
149 this.select_cell_by_id(&cell_id_for_focus, cx)
150 }
151 }
152 })
153 .detach();
154
155 let cell_id_for_editor = cell_id.clone();
156 let editor = code_cell.read(cx).editor().clone();
157 cx.subscribe(&editor, move |this, _editor, event, cx| {
158 if let editor::EditorEvent::Focused = event {
159 this.select_cell_by_id(&cell_id_for_editor, cx);
160 }
161 })
162 .detach();
163 }
164 Cell::Markdown(markdown_cell) => {
165 cx.subscribe(
166 markdown_cell,
167 move |_this, cell, event: &MarkdownCellEvent, cx| {
168 match event {
169 MarkdownCellEvent::FinishedEditing => {
170 cell.update(cx, |cell, cx| {
171 cell.reparse_markdown(cx);
172 });
173 }
174 MarkdownCellEvent::Run(_cell_id) => {
175 // run is handled separately by move_to_next_cell
176 // Just reparse here
177 cell.update(cx, |cell, cx| {
178 cell.reparse_markdown(cx);
179 });
180 }
181 }
182 },
183 )
184 .detach();
185
186 let cell_id_for_editor = cell_id.clone();
187 let editor = markdown_cell.read(cx).editor().clone();
188 cx.subscribe(&editor, move |this, _editor, event, cx| {
189 if let editor::EditorEvent::Focused = event {
190 this.select_cell_by_id(&cell_id_for_editor, cx);
191 }
192 })
193 .detach();
194 }
195 Cell::Raw(_) => {}
196 }
197
198 cell_map.insert(cell_id.clone(), cell_entity);
199 }
200
201 let notebook_handle = cx.entity().downgrade();
202 let cell_count = cell_order.len();
203
204 let this = cx.entity();
205 let cell_list = ListState::new(cell_count, gpui::ListAlignment::Top, px(1000.));
206
207 let mut editor = Self {
208 project,
209 languages: languages.clone(),
210 worktree_id,
211 focus_handle,
212 notebook_item: notebook_item.clone(),
213 notebook_language,
214 remote_id: None,
215 cell_list,
216 notebook_mode: NotebookMode::Command,
217 selected_cell_index: 0,
218 cell_order: cell_order.clone(),
219 original_cell_order: cell_order.clone(),
220 cell_map: cell_map.clone(),
221 kernel: Kernel::Shutdown,
222 kernel_specification: None,
223 execution_requests: HashMap::default(),
224 kernel_picker_handle: PopoverMenuHandle::default(),
225 };
226 editor.launch_kernel(window, cx);
227 editor.refresh_language(cx);
228 editor.refresh_kernelspecs(cx);
229
230 cx.subscribe(¬ebook_item, |this, _item, _event, cx| {
231 this.refresh_language(cx);
232 })
233 .detach();
234
235 editor
236 }
237
238 fn refresh_kernelspecs(&mut self, cx: &mut Context<Self>) {
239 let store = ReplStore::global(cx);
240 let project = self.project.clone();
241 let worktree_id = self.worktree_id;
242
243 let refresh_task = store.update(cx, |store, cx| {
244 store.refresh_python_kernelspecs(worktree_id, &project, cx)
245 });
246
247 cx.background_spawn(refresh_task).detach_and_log_err(cx);
248 }
249
250 fn refresh_language(&mut self, cx: &mut Context<Self>) {
251 let notebook_language = self.notebook_item.read(cx).notebook_language();
252 let task = cx.spawn(async move |this, cx| {
253 let language = notebook_language.await;
254 if let Some(this) = this.upgrade() {
255 this.update(cx, |this, cx| {
256 for cell in this.cell_map.values() {
257 if let Cell::Code(code_cell) = cell {
258 code_cell.update(cx, |cell, cx| {
259 cell.set_language(language.clone(), cx);
260 });
261 }
262 }
263 });
264 }
265 language
266 });
267 self.notebook_language = task.shared();
268 }
269
270 fn has_structural_changes(&self) -> bool {
271 self.cell_order != self.original_cell_order
272 }
273
274 fn has_content_changes(&self, cx: &App) -> bool {
275 self.cell_map.values().any(|cell| cell.is_dirty(cx))
276 }
277
278 pub fn to_notebook(&self, cx: &App) -> nbformat::v4::Notebook {
279 let cells: Vec<nbformat::v4::Cell> = self
280 .cell_order
281 .iter()
282 .filter_map(|cell_id| {
283 self.cell_map
284 .get(cell_id)
285 .map(|cell| cell.to_nbformat_cell(cx))
286 })
287 .collect();
288
289 let metadata = self.notebook_item.read(cx).notebook.metadata.clone();
290
291 nbformat::v4::Notebook {
292 metadata,
293 nbformat: 4,
294 nbformat_minor: 5,
295 cells,
296 }
297 }
298
299 pub fn mark_as_saved(&mut self, cx: &mut Context<Self>) {
300 self.original_cell_order = self.cell_order.clone();
301
302 for cell in self.cell_map.values() {
303 match cell {
304 Cell::Code(code_cell) => {
305 code_cell.update(cx, |code_cell, cx| {
306 let editor = code_cell.editor();
307 editor.update(cx, |editor, cx| {
308 editor.buffer().update(cx, |buffer, cx| {
309 if let Some(buf) = buffer.as_singleton() {
310 buf.update(cx, |b, cx| {
311 let version = b.version();
312 b.did_save(version, None, cx);
313 });
314 }
315 });
316 });
317 });
318 }
319 Cell::Markdown(markdown_cell) => {
320 markdown_cell.update(cx, |markdown_cell, cx| {
321 let editor = markdown_cell.editor();
322 editor.update(cx, |editor, cx| {
323 editor.buffer().update(cx, |buffer, cx| {
324 if let Some(buf) = buffer.as_singleton() {
325 buf.update(cx, |b, cx| {
326 let version = b.version();
327 b.did_save(version, None, cx);
328 });
329 }
330 });
331 });
332 });
333 }
334 Cell::Raw(_) => {}
335 }
336 }
337 cx.notify();
338 }
339
340 fn launch_kernel(&mut self, window: &mut Window, cx: &mut Context<Self>) {
341 let spec = self.kernel_specification.clone().or_else(|| {
342 ReplStore::global(cx)
343 .read(cx)
344 .active_kernelspec(self.worktree_id, None, cx)
345 });
346
347 let spec = spec.unwrap_or_else(|| {
348 KernelSpecification::Jupyter(LocalKernelSpecification {
349 name: "python3".to_string(),
350 path: PathBuf::from("python3"),
351 kernelspec: JupyterKernelspec {
352 argv: vec![
353 "python3".to_string(),
354 "-m".to_string(),
355 "ipykernel_launcher".to_string(),
356 "-f".to_string(),
357 "{connection_file}".to_string(),
358 ],
359 display_name: "Python 3".to_string(),
360 language: "python".to_string(),
361 interrupt_mode: None,
362 metadata: None,
363 env: None,
364 },
365 })
366 });
367
368 self.launch_kernel_with_spec(spec, window, cx);
369 }
370
371 fn launch_kernel_with_spec(
372 &mut self,
373 spec: KernelSpecification,
374 window: &mut Window,
375 cx: &mut Context<Self>,
376 ) {
377 let entity_id = cx.entity_id();
378 let working_directory = self
379 .project
380 .read(cx)
381 .worktree_for_id(self.worktree_id, cx)
382 .map(|worktree| worktree.read(cx).abs_path().to_path_buf())
383 .unwrap_or_else(std::env::temp_dir);
384 let fs = self.project.read(cx).fs().clone();
385 let view = cx.entity();
386
387 self.kernel_specification = Some(spec.clone());
388
389 self.notebook_item.update(cx, |item, cx| {
390 let kernel_name = spec.name().to_string();
391 let language = spec.language().to_string();
392
393 let display_name = match &spec {
394 KernelSpecification::Jupyter(s) => s.kernelspec.display_name.clone(),
395 KernelSpecification::PythonEnv(s) => s.kernelspec.display_name.clone(),
396 KernelSpecification::JupyterServer(s) => s.kernelspec.display_name.clone(),
397 KernelSpecification::SshRemote(s) => s.kernelspec.display_name.clone(),
398 KernelSpecification::WslRemote(s) => s.kernelspec.display_name.clone(),
399 };
400
401 let kernelspec_json = serde_json::json!({
402 "display_name": display_name,
403 "name": kernel_name,
404 "language": language
405 });
406
407 if let Ok(k) = serde_json::from_value(kernelspec_json) {
408 item.notebook.metadata.kernelspec = Some(k);
409 cx.emit(());
410 }
411 });
412
413 let kernel_task = match spec {
414 KernelSpecification::Jupyter(local_spec) => NativeRunningKernel::new(
415 local_spec,
416 entity_id,
417 working_directory,
418 fs,
419 view,
420 window,
421 cx,
422 ),
423 KernelSpecification::PythonEnv(env_spec) => NativeRunningKernel::new(
424 env_spec.as_local_spec(),
425 entity_id,
426 working_directory,
427 fs,
428 view,
429 window,
430 cx,
431 ),
432 KernelSpecification::JupyterServer(remote_spec) => {
433 RemoteRunningKernel::new(remote_spec, working_directory, view, window, cx)
434 }
435
436 KernelSpecification::SshRemote(spec) => {
437 let project = self.project.clone();
438 SshRunningKernel::new(spec, working_directory, project, view, window, cx)
439 }
440 KernelSpecification::WslRemote(spec) => {
441 WslRunningKernel::new(spec, entity_id, working_directory, fs, view, window, cx)
442 }
443 };
444
445 let pending_kernel = cx
446 .spawn(async move |this, cx| {
447 let kernel = kernel_task.await;
448
449 match kernel {
450 Ok(kernel) => {
451 this.update(cx, |editor, cx| {
452 editor.kernel = Kernel::RunningKernel(kernel);
453 cx.notify();
454 })
455 .ok();
456 }
457 Err(err) => {
458 log::error!("Kernel failed to start: {:?}", err);
459 this.update(cx, |editor, cx| {
460 editor.kernel = Kernel::ErroredLaunch(err.to_string());
461 cx.notify();
462 })
463 .ok();
464 }
465 }
466 })
467 .shared();
468
469 self.kernel = Kernel::StartingKernel(pending_kernel);
470 cx.notify();
471 }
472
473 // Note: Python environments are only detected as kernels if ipykernel is installed.
474 // Users need to run `pip install ipykernel` (or `uv pip install ipykernel`) in their
475 // virtual environment for it to appear in the kernel selector.
476 // This happens because we have an ipykernel check inside the function python_env_kernel_specification in mod.rs L:121
477
478 fn change_kernel(
479 &mut self,
480 spec: KernelSpecification,
481 window: &mut Window,
482 cx: &mut Context<Self>,
483 ) {
484 if let Kernel::RunningKernel(kernel) = &mut self.kernel {
485 kernel.force_shutdown(window, cx).detach();
486 }
487
488 self.execution_requests.clear();
489
490 self.launch_kernel_with_spec(spec, window, cx);
491 }
492
493 fn restart_kernel(&mut self, _: &RestartKernel, window: &mut Window, cx: &mut Context<Self>) {
494 if let Some(spec) = self.kernel_specification.clone() {
495 if let Kernel::RunningKernel(kernel) = &mut self.kernel {
496 kernel.force_shutdown(window, cx).detach();
497 }
498
499 self.kernel = Kernel::Restarting;
500 cx.notify();
501
502 self.launch_kernel_with_spec(spec, window, cx);
503 }
504 }
505
506 fn interrupt_kernel(
507 &mut self,
508 _: &InterruptKernel,
509 _window: &mut Window,
510 cx: &mut Context<Self>,
511 ) {
512 if let Kernel::RunningKernel(kernel) = &self.kernel {
513 let interrupt_request = runtimelib::InterruptRequest {};
514 let message: JupyterMessage = interrupt_request.into();
515 kernel.request_tx().try_send(message).ok();
516 cx.notify();
517 }
518 }
519
520 fn execute_cell(&mut self, cell_id: CellId, window: &mut Window, cx: &mut Context<Self>) {
521 let code = if let Some(Cell::Code(cell)) = self.cell_map.get(&cell_id) {
522 let editor = cell.read(cx).editor().clone();
523 let buffer = editor.read(cx).buffer().read(cx);
524 buffer
525 .as_singleton()
526 .map(|b| b.read(cx).text())
527 .unwrap_or_default()
528 } else {
529 return;
530 };
531
532 let request = ExecuteRequest {
533 code,
534 ..Default::default()
535 };
536 let message: JupyterMessage = request.into();
537 let msg_id = message.header.msg_id.clone();
538
539 let send_result = match &mut self.kernel {
540 Kernel::RunningKernel(kernel) => kernel
541 .request_tx()
542 .try_send(message)
543 .map_err(|err| format!("failed to send execute request to kernel (the kernel process may have died): {err}")),
544 Kernel::StartingKernel(_) => Err("the kernel is still starting".to_string()),
545 Kernel::ErroredLaunch(error) => Err(format!("the kernel failed to launch: {error}")),
546 Kernel::ShuttingDown | Kernel::Shutdown => Err("the kernel is shut down".to_string()),
547 Kernel::Restarting => Err("the kernel is restarting".to_string()),
548 };
549
550 if let Some(Cell::Code(cell)) = self.cell_map.get(&cell_id) {
551 cell.update(cx, |cell, cx| {
552 if cell.has_outputs() {
553 cell.clear_outputs();
554 }
555 if let Err(error) = &send_result {
556 cell.show_kernel_error(error, window, cx);
557 } else {
558 cell.start_execution();
559 }
560 cx.notify();
561 });
562 }
563
564 if let Err(error) = send_result {
565 log::error!("notebook: cannot execute cell: {error}");
566 } else {
567 self.execution_requests.insert(msg_id, cell_id.clone());
568 }
569 }
570
571 fn get_selected_cell(&self) -> Option<&Cell> {
572 self.cell_order
573 .get(self.selected_cell_index)
574 .and_then(|cell_id| self.cell_map.get(cell_id))
575 }
576
577 fn has_outputs(&self, window: &mut Window, cx: &mut Context<Self>) -> bool {
578 self.cell_map.values().any(|cell| {
579 if let Cell::Code(code_cell) = cell {
580 code_cell.read(cx).has_outputs()
581 } else {
582 false
583 }
584 })
585 }
586
587 fn clear_outputs(&mut self, window: &mut Window, cx: &mut Context<Self>) {
588 for cell in self.cell_map.values() {
589 if let Cell::Code(code_cell) = cell {
590 code_cell.update(cx, |cell, cx| {
591 cell.clear_outputs();
592 cx.notify();
593 });
594 }
595 }
596 cx.notify();
597 }
598
599 fn run_cells(&mut self, window: &mut Window, cx: &mut Context<Self>) {
600 for cell_id in self.cell_order.clone() {
601 self.execute_cell(cell_id, window, cx);
602 }
603 }
604
605 fn run_current_cell(&mut self, _: &Run, window: &mut Window, cx: &mut Context<Self>) {
606 let Some(cell_id) = self.cell_order.get(self.selected_cell_index).cloned() else {
607 return;
608 };
609 let Some(cell) = self.cell_map.get(&cell_id) else {
610 return;
611 };
612 match cell {
613 Cell::Code(_) => {
614 self.execute_cell(cell_id, window, cx);
615 }
616 Cell::Markdown(markdown_cell) => {
617 // for markdown, finish editing and move to next cell
618 let is_editing = markdown_cell.read(cx).is_editing();
619 if is_editing {
620 markdown_cell.update(cx, |cell, cx| {
621 cell.run(cx);
622 });
623 self.enter_command_mode(window, cx);
624 }
625 }
626 Cell::Raw(_) => {}
627 }
628 }
629
630 fn run_and_advance(&mut self, _: &RunAndAdvance, window: &mut Window, cx: &mut Context<Self>) {
631 if let Some(cell_id) = self.cell_order.get(self.selected_cell_index).cloned() {
632 if let Some(cell) = self.cell_map.get(&cell_id) {
633 match cell {
634 Cell::Code(_) => {
635 self.execute_cell(cell_id, window, cx);
636 }
637 Cell::Markdown(markdown_cell) => {
638 if markdown_cell.read(cx).is_editing() {
639 markdown_cell.update(cx, |cell, cx| {
640 cell.run(cx);
641 });
642 }
643 }
644 Cell::Raw(_) => {}
645 }
646 }
647 }
648
649 let is_last_cell = self.selected_cell_index == self.cell_count().saturating_sub(1);
650 if is_last_cell {
651 self.add_code_block(window, cx);
652 self.enter_command_mode(window, cx);
653 } else {
654 self.advance_in_command_mode(window, cx);
655 }
656 }
657
658 fn enter_edit_mode(&mut self, _: &EnterEditMode, window: &mut Window, cx: &mut Context<Self>) {
659 self.notebook_mode = NotebookMode::Edit;
660 if let Some(cell_id) = self.cell_order.get(self.selected_cell_index) {
661 if let Some(cell) = self.cell_map.get(cell_id) {
662 match cell {
663 Cell::Code(code_cell) => {
664 let editor = code_cell.read(cx).editor().clone();
665 window.focus(&editor.focus_handle(cx), cx);
666 }
667 Cell::Markdown(markdown_cell) => {
668 markdown_cell.update(cx, |cell, cx| {
669 cell.set_editing(true);
670 cx.notify();
671 });
672 let editor = markdown_cell.read(cx).editor().clone();
673 window.focus(&editor.focus_handle(cx), cx);
674 }
675 Cell::Raw(_) => {}
676 }
677 }
678 }
679 cx.notify();
680 }
681
682 fn enter_command_mode(&mut self, window: &mut Window, cx: &mut Context<Self>) {
683 self.notebook_mode = NotebookMode::Command;
684 self.focus_handle.focus(window, cx);
685 cx.notify();
686 }
687
688 fn handle_enter_command_mode(
689 &mut self,
690 _: &EnterCommandMode,
691 window: &mut Window,
692 cx: &mut Context<Self>,
693 ) {
694 self.enter_command_mode(window, cx);
695 }
696
697 /// Advances to the next cell while staying in command mode (used by RunAndAdvance and shift-enter).
698 fn advance_in_command_mode(&mut self, window: &mut Window, cx: &mut Context<Self>) {
699 let count = self.cell_count();
700 if count == 0 {
701 return;
702 }
703 if self.selected_cell_index < count - 1 {
704 self.selected_cell_index += 1;
705 self.cell_list
706 .scroll_to_reveal_item(self.selected_cell_index);
707 }
708 self.notebook_mode = NotebookMode::Command;
709 self.focus_handle.focus(window, cx);
710 cx.notify();
711 }
712
713 // Discussion can be done on this default implementation
714 /// Moves focus to the next cell editor (used when already in edit mode).
715 fn move_to_next_cell(&mut self, window: &mut Window, cx: &mut Context<Self>) {
716 if !self.cell_order.is_empty() && self.selected_cell_index < self.cell_order.len() - 1 {
717 self.selected_cell_index += 1;
718 // focus the new cell's editor
719 if let Some(cell_id) = self.cell_order.get(self.selected_cell_index) {
720 if let Some(cell) = self.cell_map.get(cell_id) {
721 match cell {
722 Cell::Code(code_cell) => {
723 let editor = code_cell.read(cx).editor();
724 window.focus(&editor.focus_handle(cx), cx);
725 }
726 Cell::Markdown(markdown_cell) => {
727 // Don't auto-enter edit mode for next markdown cell
728 // Just select it
729 }
730 Cell::Raw(_) => {}
731 }
732 }
733 }
734 cx.notify();
735 } else {
736 // in the end, could optionally create a new cell
737 // For now, just stay on the current cell
738 }
739 }
740
741 fn open_notebook(&mut self, _: &OpenNotebook, _window: &mut Window, _cx: &mut Context<Self>) {
742 println!("Open notebook triggered");
743 }
744
745 fn move_cell_up(&mut self, window: &mut Window, cx: &mut Context<Self>) {
746 println!("Move cell up triggered");
747 if self.selected_cell_index > 0 {
748 self.cell_order
749 .swap(self.selected_cell_index, self.selected_cell_index - 1);
750 self.selected_cell_index -= 1;
751 cx.notify();
752 }
753 }
754
755 fn move_cell_down(&mut self, window: &mut Window, cx: &mut Context<Self>) {
756 println!("Move cell down triggered");
757 if !self.cell_order.is_empty() && self.selected_cell_index < self.cell_order.len() - 1 {
758 self.cell_order
759 .swap(self.selected_cell_index, self.selected_cell_index + 1);
760 self.selected_cell_index += 1;
761 cx.notify();
762 }
763 }
764
765 fn insert_cell_at_current_position(&mut self, cell_id: CellId, cell: Cell) {
766 let insert_index = if self.cell_order.is_empty() {
767 0
768 } else {
769 self.selected_cell_index + 1
770 };
771 self.cell_order.insert(insert_index, cell_id.clone());
772 self.cell_map.insert(cell_id, cell);
773 self.selected_cell_index = insert_index;
774 self.cell_list.splice(insert_index..insert_index, 1);
775 self.cell_list.scroll_to_reveal_item(insert_index);
776 }
777
778 fn add_markdown_block(&mut self, window: &mut Window, cx: &mut Context<Self>) {
779 let new_cell_id: CellId = Uuid::new_v4().into();
780 let languages = self.languages.clone();
781 let metadata: nbformat::v4::CellMetadata =
782 serde_json::from_str("{}").expect("empty object should parse");
783
784 let markdown_cell = cx.new(|cx| {
785 super::MarkdownCell::new(
786 new_cell_id.clone(),
787 metadata,
788 String::new(),
789 languages,
790 window,
791 cx,
792 )
793 });
794
795 cx.subscribe(
796 &markdown_cell,
797 move |_this, cell, event: &MarkdownCellEvent, cx| match event {
798 MarkdownCellEvent::FinishedEditing | MarkdownCellEvent::Run(_) => {
799 cell.update(cx, |cell, cx| {
800 cell.reparse_markdown(cx);
801 });
802 }
803 },
804 )
805 .detach();
806
807 let cell_id_for_editor = new_cell_id.clone();
808 let editor = markdown_cell.read(cx).editor().clone();
809 cx.subscribe(&editor, move |this, _editor, event, cx| {
810 if let editor::EditorEvent::Focused = event {
811 this.select_cell_by_id(&cell_id_for_editor, cx);
812 }
813 })
814 .detach();
815
816 self.insert_cell_at_current_position(new_cell_id, Cell::Markdown(markdown_cell.clone()));
817 markdown_cell.update(cx, |cell, cx| {
818 cell.set_editing(true);
819 cx.notify();
820 });
821 let editor = markdown_cell.read(cx).editor().clone();
822 window.focus(&editor.focus_handle(cx), cx);
823 self.notebook_mode = NotebookMode::Edit;
824 cx.notify();
825 }
826
827 fn add_code_block(&mut self, window: &mut Window, cx: &mut Context<Self>) {
828 let new_cell_id: CellId = Uuid::new_v4().into();
829 let notebook_language = self.notebook_language.clone();
830 let metadata: nbformat::v4::CellMetadata =
831 serde_json::from_str("{}").expect("empty object should parse");
832
833 let code_cell = cx.new(|cx| {
834 super::CodeCell::new(
835 super::CellSource::None,
836 new_cell_id.clone(),
837 metadata,
838 String::new(),
839 notebook_language,
840 window,
841 cx,
842 )
843 });
844
845 let cell_id_for_run = new_cell_id.clone();
846 cx.subscribe_in(
847 &code_cell,
848 window,
849 move |this, _cell, event, window, cx| match event {
850 CellEvent::Run(cell_id) => this.execute_cell(cell_id.clone(), window, cx),
851 CellEvent::FocusedIn(_) => this.select_cell_by_id(&cell_id_for_run, cx),
852 },
853 )
854 .detach();
855
856 let cell_id_for_editor = new_cell_id.clone();
857 let editor = code_cell.read(cx).editor().clone();
858 cx.subscribe(&editor, move |this, _editor, event, cx| {
859 if let editor::EditorEvent::Focused = event {
860 this.select_cell_by_id(&cell_id_for_editor, cx);
861 }
862 })
863 .detach();
864
865 self.insert_cell_at_current_position(new_cell_id, Cell::Code(code_cell.clone()));
866 let editor = code_cell.read(cx).editor().clone();
867 window.focus(&editor.focus_handle(cx), cx);
868 self.notebook_mode = NotebookMode::Edit;
869 cx.notify();
870 }
871
872 fn cell_count(&self) -> usize {
873 self.cell_map.len()
874 }
875
876 fn selected_index(&self) -> usize {
877 self.selected_cell_index
878 }
879
880 fn select_cell_by_id(&mut self, cell_id: &CellId, cx: &mut Context<Self>) {
881 if let Some(index) = self.cell_order.iter().position(|id| id == cell_id) {
882 self.selected_cell_index = index;
883 self.notebook_mode = NotebookMode::Edit;
884 cx.notify();
885 }
886 }
887
888 pub fn set_selected_index(
889 &mut self,
890 index: usize,
891 jump_to_index: bool,
892 window: &mut Window,
893 cx: &mut Context<Self>,
894 ) {
895 // let previous_index = self.selected_cell_index;
896 self.selected_cell_index = index;
897 let current_index = self.selected_cell_index;
898
899 // in the future we may have some `on_cell_change` event that we want to fire here
900
901 if jump_to_index {
902 self.jump_to_cell(current_index, window, cx);
903 }
904 }
905
906 fn select_next(
907 &mut self,
908 _: &menu::SelectNext,
909 selection_mode: SelectionMode,
910 window: &mut Window,
911 cx: &mut Context<Self>,
912 ) {
913 let count = self.cell_count();
914 if count > 0 {
915 let index = self.selected_index();
916 let ix = if index == count - 1 {
917 count - 1
918 } else {
919 index + 1
920 };
921 self.set_selected_index(ix, true, window, cx);
922
923 if selection_mode == SelectionMode::SelectAndMove
924 && let Some(cell) = self.get_selected_cell()
925 {
926 cell.move_to(MovementDirection::Start, window, cx);
927 }
928
929 cx.notify();
930 }
931 }
932
933 fn select_previous(
934 &mut self,
935 _: &menu::SelectPrevious,
936 selection_mode: SelectionMode,
937 window: &mut Window,
938 cx: &mut Context<Self>,
939 ) {
940 let count = self.cell_count();
941 if count > 0 {
942 let index = self.selected_index();
943 let ix = if index == 0 { 0 } else { index - 1 };
944 self.set_selected_index(ix, true, window, cx);
945
946 if selection_mode == SelectionMode::SelectAndMove
947 && let Some(cell) = self.get_selected_cell()
948 {
949 cell.move_to(MovementDirection::End, window, cx);
950 }
951
952 cx.notify();
953 }
954 }
955
956 pub fn select_first(
957 &mut self,
958 _: &menu::SelectFirst,
959 window: &mut Window,
960 cx: &mut Context<Self>,
961 ) {
962 let count = self.cell_count();
963 if count > 0 {
964 self.set_selected_index(0, true, window, cx);
965 cx.notify();
966 }
967 }
968
969 pub fn select_last(
970 &mut self,
971 _: &menu::SelectLast,
972 window: &mut Window,
973 cx: &mut Context<Self>,
974 ) {
975 let count = self.cell_count();
976 if count > 0 {
977 self.set_selected_index(count - 1, true, window, cx);
978 cx.notify();
979 }
980 }
981
982 fn jump_to_cell(&mut self, index: usize, _window: &mut Window, _cx: &mut Context<Self>) {
983 self.cell_list.scroll_to_reveal_item(index);
984 }
985
986 fn button_group(window: &mut Window, cx: &mut Context<Self>) -> Div {
987 v_flex()
988 .gap(DynamicSpacing::Base04.rems(cx))
989 .items_center()
990 .w(px(CONTROL_SIZE + 4.0))
991 .overflow_hidden()
992 .rounded(px(5.))
993 .bg(cx.theme().colors().title_bar_background)
994 .p_px()
995 .border_1()
996 .border_color(cx.theme().colors().border)
997 }
998
999 fn render_notebook_control(
1000 id: impl Into<SharedString>,
1001 icon: IconName,
1002 _window: &mut Window,
1003 _cx: &mut Context<Self>,
1004 ) -> IconButton {
1005 let id: ElementId = ElementId::Name(id.into());
1006 IconButton::new(id, icon).width(px(CONTROL_SIZE))
1007 }
1008
1009 fn render_notebook_controls(
1010 &self,
1011 window: &mut Window,
1012 cx: &mut Context<Self>,
1013 ) -> impl IntoElement {
1014 let has_outputs = self.has_outputs(window, cx);
1015
1016 v_flex()
1017 .max_w(px(CONTROL_SIZE + 4.0))
1018 .items_center()
1019 .gap(DynamicSpacing::Base16.rems(cx))
1020 .justify_between()
1021 .flex_none()
1022 .h_full()
1023 .py(DynamicSpacing::Base12.px(cx))
1024 .child(
1025 v_flex()
1026 .gap(DynamicSpacing::Base08.rems(cx))
1027 .child(
1028 Self::button_group(window, cx)
1029 .child(
1030 Self::render_notebook_control(
1031 "run-all-cells",
1032 IconName::PlayFilled,
1033 window,
1034 cx,
1035 )
1036 .tooltip(move |window, cx| {
1037 Tooltip::for_action("Execute all cells", &RunAll, cx)
1038 })
1039 .on_click(|_, window, cx| {
1040 window.dispatch_action(Box::new(RunAll), cx);
1041 }),
1042 )
1043 .child(
1044 Self::render_notebook_control(
1045 "clear-all-outputs",
1046 IconName::ListX,
1047 window,
1048 cx,
1049 )
1050 .disabled(!has_outputs)
1051 .tooltip(move |window, cx| {
1052 Tooltip::for_action("Clear all outputs", &ClearOutputs, cx)
1053 })
1054 .on_click(|_, window, cx| {
1055 window.dispatch_action(Box::new(ClearOutputs), cx);
1056 }),
1057 ),
1058 )
1059 .child(
1060 Self::button_group(window, cx)
1061 .child(
1062 Self::render_notebook_control(
1063 "move-cell-up",
1064 IconName::ArrowUp,
1065 window,
1066 cx,
1067 )
1068 .tooltip(move |window, cx| {
1069 Tooltip::for_action("Move cell up", &MoveCellUp, cx)
1070 })
1071 .on_click(|_, window, cx| {
1072 window.dispatch_action(Box::new(MoveCellUp), cx);
1073 }),
1074 )
1075 .child(
1076 Self::render_notebook_control(
1077 "move-cell-down",
1078 IconName::ArrowDown,
1079 window,
1080 cx,
1081 )
1082 .tooltip(move |window, cx| {
1083 Tooltip::for_action("Move cell down", &MoveCellDown, cx)
1084 })
1085 .on_click(|_, window, cx| {
1086 window.dispatch_action(Box::new(MoveCellDown), cx);
1087 }),
1088 ),
1089 )
1090 .child(
1091 Self::button_group(window, cx)
1092 .child(
1093 Self::render_notebook_control(
1094 "new-markdown-cell",
1095 IconName::Plus,
1096 window,
1097 cx,
1098 )
1099 .tooltip(move |window, cx| {
1100 Tooltip::for_action("Add markdown block", &AddMarkdownBlock, cx)
1101 })
1102 .on_click(|_, window, cx| {
1103 window.dispatch_action(Box::new(AddMarkdownBlock), cx);
1104 }),
1105 )
1106 .child(
1107 Self::render_notebook_control(
1108 "new-code-cell",
1109 IconName::Code,
1110 window,
1111 cx,
1112 )
1113 .tooltip(move |window, cx| {
1114 Tooltip::for_action("Add code block", &AddCodeBlock, cx)
1115 })
1116 .on_click(|_, window, cx| {
1117 window.dispatch_action(Box::new(AddCodeBlock), cx);
1118 }),
1119 ),
1120 ),
1121 )
1122 .child(
1123 v_flex()
1124 .gap(DynamicSpacing::Base08.rems(cx))
1125 .items_center()
1126 .child(
1127 Self::render_notebook_control("more-menu", IconName::Ellipsis, window, cx)
1128 .tooltip(move |window, cx| (Tooltip::text("More options"))(window, cx)),
1129 )
1130 .child(Self::button_group(window, cx).child({
1131 let kernel_status = self.kernel.status();
1132 let (icon, icon_color) = match &kernel_status {
1133 KernelStatus::Idle => (IconName::ReplNeutral, Color::Success),
1134 KernelStatus::Busy => (IconName::ReplNeutral, Color::Warning),
1135 KernelStatus::Starting => (IconName::ReplNeutral, Color::Muted),
1136 KernelStatus::Error => (IconName::ReplNeutral, Color::Error),
1137 KernelStatus::ShuttingDown => (IconName::ReplNeutral, Color::Muted),
1138 KernelStatus::Shutdown => (IconName::ReplNeutral, Color::Disabled),
1139 KernelStatus::Restarting => (IconName::ReplNeutral, Color::Warning),
1140 };
1141 let kernel_name = self
1142 .kernel_specification
1143 .as_ref()
1144 .map(|spec| spec.name().to_string())
1145 .unwrap_or_else(|| "Select Kernel".to_string());
1146 IconButton::new("repl", icon)
1147 .icon_color(icon_color)
1148 .tooltip(move |window, cx| {
1149 Tooltip::text(format!(
1150 "{} ({}). Click to change kernel.",
1151 kernel_name,
1152 kernel_status.to_string()
1153 ))(window, cx)
1154 })
1155 .on_click(cx.listener(|this, _, window, cx| {
1156 this.kernel_picker_handle.toggle(window, cx);
1157 }))
1158 })),
1159 )
1160 }
1161
1162 fn render_kernel_status_bar(
1163 &self,
1164 _window: &mut Window,
1165 cx: &mut Context<Self>,
1166 ) -> impl IntoElement {
1167 let kernel_status = self.kernel.status();
1168 let kernel_name = self
1169 .kernel_specification
1170 .as_ref()
1171 .map(|spec| spec.name().to_string())
1172 .unwrap_or_else(|| "Select Kernel".to_string());
1173
1174 let (status_icon, status_color) = match &kernel_status {
1175 KernelStatus::Idle => (IconName::Circle, Color::Success),
1176 KernelStatus::Busy => (IconName::ArrowCircle, Color::Warning),
1177 KernelStatus::Starting => (IconName::ArrowCircle, Color::Muted),
1178 KernelStatus::Error => (IconName::XCircle, Color::Error),
1179 KernelStatus::ShuttingDown => (IconName::ArrowCircle, Color::Muted),
1180 KernelStatus::Shutdown => (IconName::Circle, Color::Muted),
1181 KernelStatus::Restarting => (IconName::ArrowCircle, Color::Warning),
1182 };
1183
1184 let is_spinning = matches!(
1185 kernel_status,
1186 KernelStatus::Busy
1187 | KernelStatus::Starting
1188 | KernelStatus::ShuttingDown
1189 | KernelStatus::Restarting
1190 );
1191
1192 let status_icon_element = if is_spinning {
1193 Icon::new(status_icon)
1194 .size(IconSize::Small)
1195 .color(status_color)
1196 .with_rotate_animation(2)
1197 .into_any_element()
1198 } else {
1199 Icon::new(status_icon)
1200 .size(IconSize::Small)
1201 .color(status_color)
1202 .into_any_element()
1203 };
1204
1205 let worktree_id = self.worktree_id;
1206 let kernel_picker_handle = self.kernel_picker_handle.clone();
1207 let view = cx.entity().downgrade();
1208
1209 h_flex()
1210 .w_full()
1211 .px_3()
1212 .py_1()
1213 .gap_2()
1214 .items_center()
1215 .justify_between()
1216 .bg(cx.theme().colors().status_bar_background)
1217 .child(
1218 KernelSelector::new(
1219 Box::new(move |spec: KernelSpecification, window, cx| {
1220 if let Some(view) = view.upgrade() {
1221 view.update(cx, |this, cx| {
1222 this.change_kernel(spec, window, cx);
1223 });
1224 }
1225 }),
1226 worktree_id,
1227 Button::new("kernel-selector", kernel_name.clone())
1228 .label_size(LabelSize::Small)
1229 .start_icon(
1230 Icon::new(status_icon)
1231 .size(IconSize::Small)
1232 .color(status_color),
1233 ),
1234 Tooltip::text(format!(
1235 "Kernel: {} ({}). Click to change.",
1236 kernel_name,
1237 kernel_status.to_string()
1238 )),
1239 )
1240 .with_handle(kernel_picker_handle),
1241 )
1242 .child(
1243 h_flex()
1244 .gap_1()
1245 .child(
1246 IconButton::new("restart-kernel", IconName::RotateCw)
1247 .icon_size(IconSize::Small)
1248 .tooltip(|window, cx| {
1249 Tooltip::for_action("Restart Kernel", &RestartKernel, cx)
1250 })
1251 .on_click(cx.listener(|this, _, window, cx| {
1252 this.restart_kernel(&RestartKernel, window, cx);
1253 })),
1254 )
1255 .child(
1256 IconButton::new("interrupt-kernel", IconName::Stop)
1257 .icon_size(IconSize::Small)
1258 .disabled(!matches!(kernel_status, KernelStatus::Busy))
1259 .tooltip(|window, cx| {
1260 Tooltip::for_action("Interrupt Kernel", &InterruptKernel, cx)
1261 })
1262 .on_click(cx.listener(|this, _, window, cx| {
1263 this.interrupt_kernel(&InterruptKernel, window, cx);
1264 })),
1265 ),
1266 )
1267 }
1268
1269 fn cell_list(&self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1270 let view = cx.entity();
1271 list(self.cell_list.clone(), move |index, window, cx| {
1272 view.update(cx, |this, cx| {
1273 let cell_id = &this.cell_order[index];
1274 let cell = this.cell_map.get(cell_id).unwrap();
1275 this.render_cell(index, cell, window, cx).into_any_element()
1276 })
1277 })
1278 .size_full()
1279 }
1280
1281 fn render_empty_state(&self, cx: &mut Context<Self>) -> impl IntoElement {
1282 v_flex()
1283 .size_full()
1284 .items_center()
1285 .justify_center()
1286 .gap_3()
1287 .child(Label::new("This notebook is empty.").color(Color::Muted))
1288 .child(
1289 h_flex()
1290 .gap_2()
1291 .child(
1292 Button::new("empty-state-add-code", "Add code cell")
1293 .start_icon(Icon::new(IconName::Code))
1294 .key_binding(KeyBinding::for_action_in(
1295 &AddCodeBlock,
1296 &self.focus_handle,
1297 cx,
1298 ))
1299 .on_click(
1300 cx.listener(|this, _, window, cx| this.add_code_block(window, cx)),
1301 ),
1302 )
1303 .child(
1304 Button::new("empty-state-add-markdown", "Add markdown cell")
1305 .style(ButtonStyle::Subtle)
1306 .start_icon(Icon::new(IconName::FileMarkdown))
1307 .key_binding(KeyBinding::for_action_in(
1308 &AddMarkdownBlock,
1309 &self.focus_handle,
1310 cx,
1311 ))
1312 .on_click(cx.listener(|this, _, window, cx| {
1313 this.add_markdown_block(window, cx)
1314 })),
1315 ),
1316 )
1317 }
1318
1319 fn cell_position(&self, index: usize) -> CellPosition {
1320 match index {
1321 0 => CellPosition::First,
1322 index if index == self.cell_count() - 1 => CellPosition::Last,
1323 _ => CellPosition::Middle,
1324 }
1325 }
1326
1327 fn render_cell(
1328 &self,
1329 index: usize,
1330 cell: &Cell,
1331 window: &mut Window,
1332 cx: &mut Context<Self>,
1333 ) -> impl IntoElement {
1334 let cell_position = self.cell_position(index);
1335
1336 let is_selected = index == self.selected_cell_index;
1337
1338 match cell {
1339 Cell::Code(cell) => {
1340 cell.update(cx, |cell, _cx| {
1341 cell.set_selected(is_selected)
1342 .set_cell_position(cell_position);
1343 });
1344 cell.clone().into_any_element()
1345 }
1346 Cell::Markdown(cell) => {
1347 cell.update(cx, |cell, _cx| {
1348 cell.set_selected(is_selected)
1349 .set_cell_position(cell_position);
1350 });
1351 cell.clone().into_any_element()
1352 }
1353 Cell::Raw(cell) => {
1354 cell.update(cx, |cell, _cx| {
1355 cell.set_selected(is_selected)
1356 .set_cell_position(cell_position);
1357 });
1358 cell.clone().into_any_element()
1359 }
1360 }
1361 }
1362}
1363
1364impl Render for NotebookEditor {
1365 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1366 let mut key_context = KeyContext::new_with_defaults();
1367 key_context.add("NotebookEditor");
1368 key_context.set(
1369 "notebook_mode",
1370 match self.notebook_mode {
1371 NotebookMode::Command => "command",
1372 NotebookMode::Edit => "edit",
1373 },
1374 );
1375
1376 v_flex()
1377 .size_full()
1378 .key_context(key_context)
1379 .track_focus(&self.focus_handle)
1380 .on_action(cx.listener(|this, _: &OpenNotebook, window, cx| {
1381 this.open_notebook(&OpenNotebook, window, cx)
1382 }))
1383 .on_action(
1384 cx.listener(|this, _: &ClearOutputs, window, cx| this.clear_outputs(window, cx)),
1385 )
1386 .on_action(
1387 cx.listener(|this, _: &Run, window, cx| this.run_current_cell(&Run, window, cx)),
1388 )
1389 .on_action(
1390 cx.listener(|this, action, window, cx| this.run_and_advance(action, window, cx)),
1391 )
1392 .on_action(cx.listener(|this, _: &RunAll, window, cx| this.run_cells(window, cx)))
1393 .on_action(
1394 cx.listener(|this, _: &MoveCellUp, window, cx| this.move_cell_up(window, cx)),
1395 )
1396 .on_action(
1397 cx.listener(|this, _: &MoveCellDown, window, cx| this.move_cell_down(window, cx)),
1398 )
1399 .on_action(cx.listener(|this, _: &AddMarkdownBlock, window, cx| {
1400 this.add_markdown_block(window, cx)
1401 }))
1402 .on_action(
1403 cx.listener(|this, _: &AddCodeBlock, window, cx| this.add_code_block(window, cx)),
1404 )
1405 .on_action(
1406 cx.listener(|this, action, window, cx| this.enter_edit_mode(action, window, cx)),
1407 )
1408 .on_action(cx.listener(|this, action, window, cx| {
1409 this.handle_enter_command_mode(action, window, cx)
1410 }))
1411 .on_action(cx.listener(|this, action, window, cx| {
1412 this.select_next(action, SelectionMode::SelectOnly, window, cx)
1413 }))
1414 .on_action(cx.listener(|this, action, window, cx| {
1415 this.select_previous(action, SelectionMode::SelectOnly, window, cx)
1416 }))
1417 .on_action(cx.listener(Self::select_first))
1418 .on_action(cx.listener(Self::select_last))
1419 .on_action(cx.listener(|this, _: &MoveDown, window, cx| {
1420 this.select_next(
1421 &Default::default(),
1422 SelectionMode::SelectAndMove,
1423 window,
1424 cx,
1425 );
1426 }))
1427 .on_action(cx.listener(|this, _: &MoveUp, window, cx| {
1428 this.select_previous(
1429 &Default::default(),
1430 SelectionMode::SelectAndMove,
1431 window,
1432 cx,
1433 );
1434 }))
1435 .on_action(cx.listener(|this, _: &NotebookMoveDown, window, cx| {
1436 let Some(cell) = this.get_selected_cell() else {
1437 return;
1438 };
1439
1440 let Some(editor) = cell.editor(cx).cloned() else {
1441 return;
1442 };
1443
1444 let is_at_last_line = editor.update(cx, |editor, cx| {
1445 let display_snapshot = editor.display_snapshot(cx);
1446 let selections = editor.selections.all_display(&display_snapshot);
1447 if let Some(selection) = selections.last() {
1448 let head = selection.head();
1449 let cursor_row = head.row();
1450 let max_row = display_snapshot.max_point().row();
1451
1452 cursor_row >= max_row
1453 } else {
1454 false
1455 }
1456 });
1457
1458 if is_at_last_line {
1459 this.select_next(
1460 &Default::default(),
1461 SelectionMode::SelectAndMove,
1462 window,
1463 cx,
1464 );
1465 } else {
1466 editor.update(cx, |editor, cx| {
1467 editor.move_down(&Default::default(), window, cx);
1468 });
1469 }
1470 }))
1471 .on_action(cx.listener(|this, _: &NotebookMoveUp, window, cx| {
1472 let Some(cell) = this.get_selected_cell() else {
1473 return;
1474 };
1475
1476 let Some(editor) = cell.editor(cx).cloned() else {
1477 return;
1478 };
1479
1480 let is_at_first_line = editor.update(cx, |editor, cx| {
1481 let display_snapshot = editor.display_snapshot(cx);
1482 let selections = editor.selections.all_display(&display_snapshot);
1483 if let Some(selection) = selections.first() {
1484 let head = selection.head();
1485 let cursor_row = head.row();
1486
1487 cursor_row.0 == 0
1488 } else {
1489 false
1490 }
1491 });
1492
1493 if is_at_first_line {
1494 this.select_previous(
1495 &Default::default(),
1496 SelectionMode::SelectAndMove,
1497 window,
1498 cx,
1499 );
1500 } else {
1501 editor.update(cx, |editor, cx| {
1502 editor.move_up(&Default::default(), window, cx);
1503 });
1504 }
1505 }))
1506 .on_action(
1507 cx.listener(|this, action, window, cx| this.restart_kernel(action, window, cx)),
1508 )
1509 .on_action(
1510 cx.listener(|this, action, window, cx| this.interrupt_kernel(action, window, cx)),
1511 )
1512 .child(
1513 h_flex()
1514 .flex_1()
1515 .w_full()
1516 .h_full()
1517 .gap_2()
1518 .child(
1519 div()
1520 .flex_1()
1521 .h_full()
1522 .child(if self.cell_order.is_empty() {
1523 self.render_empty_state(cx).into_any_element()
1524 } else {
1525 self.cell_list(window, cx).into_any_element()
1526 }),
1527 )
1528 .child(self.render_notebook_controls(window, cx)),
1529 )
1530 .child(self.render_kernel_status_bar(window, cx))
1531 }
1532}
1533
1534impl Focusable for NotebookEditor {
1535 fn focus_handle(&self, _: &App) -> FocusHandle {
1536 self.focus_handle.clone()
1537 }
1538}
1539
1540// Intended to be a NotebookBuffer
1541pub struct NotebookItem {
1542 path: PathBuf,
1543 project_path: ProjectPath,
1544 languages: Arc<LanguageRegistry>,
1545 // Raw notebook data
1546 notebook: nbformat::v4::Notebook,
1547 // Store our version of the notebook in memory (cell_order, cell_map)
1548 id: ProjectEntryId,
1549}
1550
1551impl project::ProjectItem for NotebookItem {
1552 fn try_open(
1553 project: &Entity<Project>,
1554 path: &ProjectPath,
1555 cx: &mut App,
1556 ) -> Option<Task<anyhow::Result<Entity<Self>>>> {
1557 let path = path.clone();
1558 let project = project.clone();
1559 let fs = project.read(cx).fs().clone();
1560 let languages = project.read(cx).languages().clone();
1561
1562 // For single-file worktrees the relative path is empty, so fall back
1563 // to the absolute path to detect notebooks opened directly.
1564 let abs_path = project.read(cx).absolute_path(&path, cx);
1565 let is_notebook = path.path.extension().unwrap_or_default() == NOTEBOOK_EXTENSION
1566 || abs_path
1567 .as_ref()
1568 .and_then(|abs_path| abs_path.extension())
1569 .is_some_and(|extension| extension == NOTEBOOK_EXTENSION);
1570
1571 if is_notebook {
1572 Some(cx.spawn(async move |cx| {
1573 let abs_path =
1574 abs_path.with_context(|| format!("finding the absolute path of {path:?}"))?;
1575
1576 // todo: watch for changes to the file
1577 let buffer = project
1578 .update(cx, |project, cx| project.open_buffer(path.clone(), cx))
1579 .await?;
1580 let file_content = buffer.read_with(cx, |buffer, _| buffer.text());
1581
1582 let notebook = if file_content.trim().is_empty() {
1583 nbformat::v4::Notebook {
1584 nbformat: 4,
1585 nbformat_minor: 5,
1586 cells: vec![],
1587 metadata: serde_json::from_str("{}").unwrap(),
1588 }
1589 } else {
1590 let notebook = match nbformat::parse_notebook(&file_content) {
1591 Ok(nb) => nb,
1592 Err(_) => {
1593 // Pre-process to ensure IDs exist
1594 let mut json: serde_json::Value = serde_json::from_str(&file_content)?;
1595 if let Some(cells) =
1596 json.get_mut("cells").and_then(|c| c.as_array_mut())
1597 {
1598 for cell in cells {
1599 if cell.get("id").is_none() {
1600 cell["id"] =
1601 serde_json::Value::String(Uuid::new_v4().to_string());
1602 }
1603 }
1604 }
1605 let file_content = serde_json::to_string(&json)?;
1606 nbformat::parse_notebook(&file_content)?
1607 }
1608 };
1609
1610 match notebook {
1611 nbformat::Notebook::V4(notebook) => notebook,
1612 // 4.1 - 4.4 are converted to 4.5
1613 nbformat::Notebook::Legacy(legacy_notebook) => {
1614 // TODO: Decide if we want to mutate the notebook by including Cell IDs
1615 // and any other conversions
1616
1617 nbformat::upgrade_legacy_notebook(legacy_notebook)?
1618 }
1619 nbformat::Notebook::V3(v3_notebook) => {
1620 nbformat::upgrade_v3_notebook(v3_notebook)?
1621 }
1622 }
1623 };
1624
1625 let id = project
1626 .update(cx, |project, cx| {
1627 project.entry_for_path(&path, cx).map(|entry| entry.id)
1628 })
1629 .context("Entry not found")?;
1630
1631 Ok(cx.new(|_| NotebookItem {
1632 path: abs_path,
1633 project_path: path,
1634 languages,
1635 notebook,
1636 id,
1637 }))
1638 }))
1639 } else {
1640 None
1641 }
1642 }
1643
1644 fn entry_id(&self, _: &App) -> Option<ProjectEntryId> {
1645 Some(self.id)
1646 }
1647
1648 fn project_path(&self, _: &App) -> Option<ProjectPath> {
1649 Some(self.project_path.clone())
1650 }
1651
1652 fn is_dirty(&self) -> bool {
1653 // TODO: Track if notebook metadata or structure has changed
1654 false
1655 }
1656}
1657
1658impl NotebookItem {
1659 pub fn language_name(&self) -> Option<String> {
1660 self.notebook
1661 .metadata
1662 .language_info
1663 .as_ref()
1664 .map(|l| l.name.clone())
1665 .or(self
1666 .notebook
1667 .metadata
1668 .kernelspec
1669 .as_ref()
1670 .and_then(|spec| spec.language.clone()))
1671 }
1672
1673 pub fn notebook_language(&self) -> impl Future<Output = Option<Arc<Language>>> + use<> {
1674 let language_name = self.language_name();
1675 let languages = self.languages.clone();
1676
1677 async move {
1678 if let Some(language_name) = language_name {
1679 languages.language_for_name(&language_name).await.ok()
1680 } else {
1681 None
1682 }
1683 }
1684 }
1685}
1686
1687impl EventEmitter<()> for NotebookItem {}
1688
1689impl EventEmitter<()> for NotebookEditor {}
1690
1691// pub struct NotebookControls {
1692// pane_focused: bool,
1693// active_item: Option<Box<dyn ItemHandle>>,
1694// // subscription: Option<Subscription>,
1695// }
1696
1697// impl NotebookControls {
1698// pub fn new() -> Self {
1699// Self {
1700// pane_focused: false,
1701// active_item: Default::default(),
1702// // subscription: Default::default(),
1703// }
1704// }
1705// }
1706
1707// impl EventEmitter<ToolbarItemEvent> for NotebookControls {}
1708
1709// impl Render for NotebookControls {
1710// fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1711// div().child("notebook controls")
1712// }
1713// }
1714
1715// impl ToolbarItemView for NotebookControls {
1716// fn set_active_pane_item(
1717// &mut self,
1718// active_pane_item: Option<&dyn workspace::ItemHandle>,
1719// window: &mut Window, cx: &mut Context<Self>,
1720// ) -> workspace::ToolbarItemLocation {
1721// cx.notify();
1722// self.active_item = None;
1723
1724// let Some(item) = active_pane_item else {
1725// return ToolbarItemLocation::Hidden;
1726// };
1727
1728// ToolbarItemLocation::PrimaryLeft
1729// }
1730
1731// fn pane_focus_update(&mut self, pane_focused: bool, _window: &mut Window, _cx: &mut Context<Self>) {
1732// self.pane_focused = pane_focused;
1733// }
1734// }
1735
1736impl Item for NotebookEditor {
1737 type Event = ();
1738
1739 fn can_split(&self) -> bool {
1740 true
1741 }
1742
1743 fn clone_on_split(
1744 &self,
1745 _workspace_id: Option<workspace::WorkspaceId>,
1746 window: &mut Window,
1747 cx: &mut Context<Self>,
1748 ) -> Task<Option<Entity<Self>>>
1749 where
1750 Self: Sized,
1751 {
1752 Task::ready(Some(cx.new(|cx| {
1753 Self::new(self.project.clone(), self.notebook_item.clone(), window, cx)
1754 })))
1755 }
1756
1757 fn buffer_kind(&self, _: &App) -> workspace::item::ItemBufferKind {
1758 workspace::item::ItemBufferKind::Singleton
1759 }
1760
1761 fn for_each_project_item(
1762 &self,
1763 cx: &App,
1764 f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
1765 ) {
1766 f(self.notebook_item.entity_id(), self.notebook_item.read(cx))
1767 }
1768
1769 fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
1770 self.notebook_item
1771 .read(cx)
1772 .project_path
1773 .path
1774 .file_name()
1775 .map(|s| s.to_string())
1776 .unwrap_or_default()
1777 .into()
1778 }
1779
1780 fn tab_content(&self, params: TabContentParams, window: &Window, cx: &App) -> AnyElement {
1781 Label::new(self.tab_content_text(params.detail.unwrap_or(0), cx))
1782 .single_line()
1783 .color(params.text_color())
1784 .when(params.preview, |this| this.italic())
1785 .into_any_element()
1786 }
1787
1788 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
1789 Some(IconName::Book.into())
1790 }
1791
1792 fn show_toolbar(&self) -> bool {
1793 false
1794 }
1795
1796 // TODO
1797 fn pixel_position_of_cursor(&self, _: &App) -> Option<Point<Pixels>> {
1798 None
1799 }
1800
1801 // TODO
1802 fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
1803 None
1804 }
1805
1806 fn set_nav_history(
1807 &mut self,
1808 _: workspace::ItemNavHistory,
1809 _window: &mut Window,
1810 _: &mut Context<Self>,
1811 ) {
1812 // TODO
1813 }
1814
1815 fn can_save(&self, _cx: &App) -> bool {
1816 true
1817 }
1818
1819 fn save(
1820 &mut self,
1821 _options: SaveOptions,
1822 project: Entity<Project>,
1823 _window: &mut Window,
1824 cx: &mut Context<Self>,
1825 ) -> Task<Result<()>> {
1826 let notebook = self.to_notebook(cx);
1827 let path = self.notebook_item.read(cx).path.clone();
1828 let fs = project.read(cx).fs().clone();
1829
1830 self.mark_as_saved(cx);
1831
1832 cx.spawn(async move |_this, _cx| {
1833 let json =
1834 serde_json::to_string_pretty(¬ebook).context("Failed to serialize notebook")?;
1835 fs.atomic_write(path, json).await?;
1836 Ok(())
1837 })
1838 }
1839
1840 fn save_as(
1841 &mut self,
1842 project: Entity<Project>,
1843 path: ProjectPath,
1844 _window: &mut Window,
1845 cx: &mut Context<Self>,
1846 ) -> Task<Result<()>> {
1847 let notebook = self.to_notebook(cx);
1848 let fs = project.read(cx).fs().clone();
1849
1850 let abs_path = project.read(cx).absolute_path(&path, cx);
1851
1852 self.mark_as_saved(cx);
1853
1854 cx.spawn(async move |_this, _cx| {
1855 let abs_path = abs_path.context("Failed to get absolute path")?;
1856 let json =
1857 serde_json::to_string_pretty(¬ebook).context("Failed to serialize notebook")?;
1858 fs.atomic_write(abs_path, json).await?;
1859 Ok(())
1860 })
1861 }
1862
1863 fn reload(
1864 &mut self,
1865 project: Entity<Project>,
1866 window: &mut Window,
1867 cx: &mut Context<Self>,
1868 ) -> Task<Result<()>> {
1869 let project_path = self.notebook_item.read(cx).project_path.clone();
1870 let languages = self.languages.clone();
1871 let notebook_language = self.notebook_language.clone();
1872
1873 cx.spawn_in(window, async move |this, cx| {
1874 let buffer = this
1875 .update(cx, |this, cx| {
1876 this.project
1877 .update(cx, |project, cx| project.open_buffer(project_path, cx))
1878 })?
1879 .await?;
1880
1881 let file_content = buffer.read_with(cx, |buffer, _| buffer.text());
1882
1883 let mut json: serde_json::Value = serde_json::from_str(&file_content)?;
1884 if let Some(cells) = json.get_mut("cells").and_then(|c| c.as_array_mut()) {
1885 for cell in cells {
1886 if cell.get("id").is_none() {
1887 cell["id"] = serde_json::Value::String(Uuid::new_v4().to_string());
1888 }
1889 }
1890 }
1891 let file_content = serde_json::to_string(&json)?;
1892
1893 let notebook = nbformat::parse_notebook(&file_content);
1894 let notebook = match notebook {
1895 Ok(nbformat::Notebook::V4(notebook)) => notebook,
1896 Ok(nbformat::Notebook::Legacy(legacy_notebook)) => {
1897 nbformat::upgrade_legacy_notebook(legacy_notebook)?
1898 }
1899 Ok(nbformat::Notebook::V3(v3_notebook)) => {
1900 nbformat::upgrade_v3_notebook(v3_notebook)?
1901 }
1902 Err(e) => {
1903 anyhow::bail!("Failed to parse notebook: {:?}", e);
1904 }
1905 };
1906
1907 this.update_in(cx, |this, window, cx| {
1908 let mut cell_order = vec![];
1909 let mut cell_map = HashMap::default();
1910
1911 for cell in notebook.cells.iter() {
1912 let cell_id = cell.id();
1913 cell_order.push(cell_id.clone());
1914 let cell_entity =
1915 Cell::load(cell, &languages, notebook_language.clone(), window, cx);
1916 cell_map.insert(cell_id.clone(), cell_entity);
1917 }
1918
1919 this.cell_order = cell_order.clone();
1920 this.original_cell_order = cell_order;
1921 this.cell_map = cell_map;
1922 this.cell_list =
1923 ListState::new(this.cell_order.len(), gpui::ListAlignment::Top, px(1000.));
1924 cx.notify();
1925 })?;
1926
1927 Ok(())
1928 })
1929 }
1930
1931 fn is_dirty(&self, cx: &App) -> bool {
1932 self.has_structural_changes() || self.has_content_changes(cx)
1933 }
1934}
1935
1936impl ProjectItem for NotebookEditor {
1937 type Item = NotebookItem;
1938
1939 fn for_project_item(
1940 project: Entity<Project>,
1941 _pane: Option<&Pane>,
1942 item: Entity<Self::Item>,
1943 window: &mut Window,
1944 cx: &mut Context<Self>,
1945 ) -> Self {
1946 Self::new(project, item, window, cx)
1947 }
1948}
1949
1950impl KernelSession for NotebookEditor {
1951 fn route(&mut self, message: &JupyterMessage, window: &mut Window, cx: &mut Context<Self>) {
1952 // Handle kernel status updates (these are broadcast to all)
1953 if let JupyterMessageContent::Status(status) = &message.content {
1954 self.kernel.set_execution_state(&status.execution_state);
1955 cx.notify();
1956 }
1957
1958 if let JupyterMessageContent::KernelInfoReply(reply) = &message.content {
1959 self.kernel.set_kernel_info(reply);
1960
1961 if let Ok(language_info) = serde_json::from_value::<nbformat::v4::LanguageInfo>(
1962 serde_json::to_value(&reply.language_info).unwrap(),
1963 ) {
1964 self.notebook_item.update(cx, |item, cx| {
1965 item.notebook.metadata.language_info = Some(language_info);
1966 cx.emit(());
1967 });
1968 }
1969 cx.notify();
1970 }
1971
1972 // Handle cell-specific messages
1973 if let Some(parent_header) = &message.parent_header {
1974 if let Some(cell_id) = self.execution_requests.get(&parent_header.msg_id) {
1975 if let Some(Cell::Code(cell)) = self.cell_map.get(cell_id) {
1976 cell.update(cx, |cell, cx| {
1977 cell.handle_message(message, window, cx);
1978 });
1979 }
1980 }
1981 }
1982 }
1983
1984 fn kernel_errored(&mut self, error_message: String, cx: &mut Context<Self>) {
1985 self.kernel = Kernel::ErroredLaunch(error_message);
1986 cx.notify();
1987 }
1988}
1989
1990#[cfg(test)]
1991mod tests {
1992 use super::*;
1993 use gpui::TestAppContext;
1994 use project::{FakeFs, Project, ProjectItem as _};
1995 use serde_json::json;
1996 use settings::SettingsStore;
1997 use util::path;
1998 use util::rel_path::rel_path;
1999
2000 const NOTEBOOK_WITH_ONE_CODE_CELL: &str = r#"{
2001 "metadata": {
2002 "kernelspec": {
2003 "display_name": "Python 3",
2004 "language": "python",
2005 "name": "python3"
2006 },
2007 "language_info": {
2008 "name": "python"
2009 }
2010 },
2011 "nbformat": 4,
2012 "nbformat_minor": 5,
2013 "cells": [
2014 {
2015 "cell_type": "code",
2016 "id": "cell-one",
2017 "metadata": {},
2018 "execution_count": null,
2019 "outputs": [],
2020 "source": ["print('hello')"]
2021 }
2022 ]
2023 }"#;
2024
2025 /// When the configured interpreter doesn't exist (e.g. Python isn't installed),
2026 /// running a cell must not leave it stuck in the executing state. It should
2027 /// instead surface the kernel launch error as an error output on the cell.
2028 #[gpui::test]
2029 async fn test_run_cell_with_missing_interpreter_shows_error(cx: &mut TestAppContext) {
2030 cx.update(|cx| {
2031 let settings_store = SettingsStore::test(cx);
2032 cx.set_global(settings_store);
2033 theme_settings::init(theme::LoadThemes::JustBase, cx);
2034 editor::init(cx);
2035 });
2036
2037 let fs = FakeFs::new(cx.executor());
2038 fs.insert_tree(
2039 path!("/notebooks"),
2040 json!({ "test.ipynb": NOTEBOOK_WITH_ONE_CODE_CELL }),
2041 )
2042 .await;
2043
2044 let project = Project::test(fs.clone(), [path!("/notebooks").as_ref()], cx).await;
2045 cx.update(|cx| ReplStore::init(fs.clone(), cx));
2046
2047 let worktree_id = project.read_with(cx, |project, cx| {
2048 project.worktrees(cx).next().unwrap().read(cx).id()
2049 });
2050
2051 // Select a kernel whose interpreter doesn't exist, simulating a machine
2052 // where Python isn't installed properly. This is the same path the
2053 // kernel picker uses.
2054 let missing_interpreter = path!("/nonexistent/python3");
2055 let broken_spec = KernelSpecification::Jupyter(LocalKernelSpecification {
2056 name: "python3".to_string(),
2057 path: PathBuf::from(missing_interpreter),
2058 kernelspec: JupyterKernelspec {
2059 argv: vec![
2060 missing_interpreter.to_string(),
2061 "-m".to_string(),
2062 "ipykernel_launcher".to_string(),
2063 "-f".to_string(),
2064 "{connection_file}".to_string(),
2065 ],
2066 display_name: "Python 3".to_string(),
2067 language: "python".to_string(),
2068 interrupt_mode: None,
2069 metadata: None,
2070 env: None,
2071 },
2072 });
2073 cx.update(|cx| {
2074 ReplStore::global(cx).update(cx, |store, cx| {
2075 store.set_active_kernelspec(worktree_id, broken_spec, cx);
2076 })
2077 });
2078
2079 let notebook_item = cx
2080 .update(|cx| {
2081 NotebookItem::try_open(
2082 &project,
2083 &ProjectPath {
2084 worktree_id,
2085 path: rel_path("test.ipynb").into(),
2086 },
2087 cx,
2088 )
2089 .expect("ipynb files should be openable as notebooks")
2090 })
2091 .await
2092 .expect("notebook should parse");
2093
2094 // Don't render the notebook UI itself: its animated kernel status icon
2095 // schedules a new frame on every render, which makes `run_until_parked`
2096 // spin forever in tests. The editor entity is created inside an empty
2097 // window instead; we are testing execution behavior, not rendering.
2098 let cx = cx.add_empty_window();
2099
2100 // Launching a kernel probes real TCP ports on localhost, which the
2101 // deterministic test scheduler cannot drive.
2102 cx.executor().allow_parking();
2103
2104 let editor = cx.update(|window, cx| {
2105 cx.new(|cx| NotebookEditor::new(project.clone(), notebook_item, window, cx))
2106 });
2107
2108 // Creating the editor launches the kernel. Wait for the actual launch
2109 // task, which fails because the interpreter cannot be spawned.
2110 let pending_kernel = editor.read_with(cx, |editor, _| match &editor.kernel {
2111 Kernel::StartingKernel(task) => task.clone(),
2112 _ => panic!("kernel should be starting right after the editor is created"),
2113 });
2114 pending_kernel.await;
2115
2116 editor.read_with(cx, |editor, _| {
2117 assert!(
2118 matches!(editor.kernel, Kernel::ErroredLaunch(_)),
2119 "kernel launch should fail, instead status is: {}",
2120 editor.kernel.status().to_string()
2121 );
2122 });
2123
2124 // Run the (only) cell via the production action handler.
2125 editor.update_in(cx, |editor, window, cx| {
2126 editor.run_current_cell(&Run, window, cx);
2127 });
2128
2129 editor.read_with(cx, |editor, cx| {
2130 let cell_id = editor.cell_order.first().expect("notebook has one cell");
2131 let Some(Cell::Code(cell)) = editor.cell_map.get(cell_id) else {
2132 panic!("expected a code cell");
2133 };
2134 let cell = cell.read(cx);
2135
2136 assert!(
2137 !cell.is_executing(),
2138 "cell must not be stuck in the executing state when the kernel is not running"
2139 );
2140
2141 let nbformat::v4::Cell::Code { outputs, .. } = cell.to_nbformat_cell(cx) else {
2142 panic!("expected a code cell");
2143 };
2144 match outputs.as_slice() {
2145 [nbformat::v4::Output::Error(error)] => {
2146 assert_eq!(error.ename, "Kernel Error");
2147 let traceback = error.traceback.join("\n");
2148 assert!(
2149 traceback.contains("the kernel failed to launch"),
2150 "error output should explain why the cell could not run, got: {traceback}"
2151 );
2152 }
2153 other => panic!("expected a single error output, got: {other:?}"),
2154 }
2155 });
2156 }
2157
2158 /// Opening a notebook as a single file (its own worktree) leaves the
2159 /// worktree-relative path empty, so only the absolute path carries the
2160 /// `.ipynb` extension. `try_open` must still recognize it as a notebook.
2161 #[gpui::test]
2162 async fn test_open_single_file_notebook(cx: &mut TestAppContext) {
2163 cx.update(|cx| {
2164 let settings_store = SettingsStore::test(cx);
2165 cx.set_global(settings_store);
2166 theme_settings::init(theme::LoadThemes::JustBase, cx);
2167 editor::init(cx);
2168 });
2169
2170 let fs = FakeFs::new(cx.executor());
2171 fs.insert_tree(
2172 path!("/notebooks"),
2173 json!({ "single.ipynb": NOTEBOOK_WITH_ONE_CODE_CELL }),
2174 )
2175 .await;
2176
2177 let project =
2178 Project::test(fs.clone(), [path!("/notebooks/single.ipynb").as_ref()], cx).await;
2179 cx.update(|cx| ReplStore::init(fs.clone(), cx));
2180
2181 let project_path = project.read_with(cx, |project, cx| {
2182 let worktree = project.worktrees(cx).next().unwrap();
2183 let worktree = worktree.read(cx);
2184 assert!(
2185 worktree.is_single_file(),
2186 "opening a bare .ipynb should create a single-file worktree"
2187 );
2188 ProjectPath {
2189 worktree_id: worktree.id(),
2190 path: worktree.root_entry().unwrap().path.clone(),
2191 }
2192 });
2193
2194 assert!(
2195 project_path.path.extension().is_none(),
2196 "single-file worktree relative path should have no extension"
2197 );
2198
2199 let notebook_item = cx
2200 .update(|cx| {
2201 NotebookItem::try_open(&project, &project_path, cx)
2202 .expect("single-file .ipynb should open as a notebook")
2203 })
2204 .await
2205 .expect("notebook should parse");
2206
2207 notebook_item.read_with(cx, |item, _| {
2208 assert_eq!(item.notebook.cells.len(), 1);
2209 });
2210 }
2211}
2212