Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:57:16.875Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

notebook_ui.rs

2212 lines · 82.3 KB · rust
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(&notebook_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(&notebook).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(&notebook).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
Served at tenant.openagents/omega Member data and write actions are omitted.