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 feature_flags::{FeatureFlagAppExt as _, NotebookFeatureFlag};
  9use futures::FutureExt;
 10use futures::future::Shared;
 11use gpui::{
 12    AnyElement, App, Entity, EventEmitter, FocusHandle, Focusable, ListScrollEvent, ListState,
 13    Point, Task, actions, list, prelude::*,
 14};
 15use language::{Language, LanguageRegistry};
 16use project::{Project, ProjectEntryId, ProjectPath};
 17use ui::{Tooltip, prelude::*};
 18use workspace::item::{ItemEvent, TabContentParams};
 19use workspace::searchable::SearchableItemHandle;
 20use workspace::{Item, ItemHandle, Pane, ProjectItem, ToolbarItemLocation};
 21use workspace::{ToolbarItemEvent, ToolbarItemView};
 22
 23use super::{Cell, CellPosition, RenderableCell};
 24
 25use nbformat::v4::CellId;
 26use nbformat::v4::Metadata as NotebookMetadata;
 27
 28actions!(
 29    notebook,
 30    [
 31        OpenNotebook,
 32        RunAll,
 33        ClearOutputs,
 34        MoveCellUp,
 35        MoveCellDown,
 36        AddMarkdownBlock,
 37        AddCodeBlock,
 38    ]
 39);
 40
 41pub(crate) const MAX_TEXT_BLOCK_WIDTH: f32 = 9999.0;
 42pub(crate) const SMALL_SPACING_SIZE: f32 = 8.0;
 43pub(crate) const MEDIUM_SPACING_SIZE: f32 = 12.0;
 44pub(crate) const LARGE_SPACING_SIZE: f32 = 16.0;
 45pub(crate) const GUTTER_WIDTH: f32 = 19.0;
 46pub(crate) const CODE_BLOCK_INSET: f32 = MEDIUM_SPACING_SIZE;
 47pub(crate) const CONTROL_SIZE: f32 = 20.0;
 48
 49pub fn init(cx: &mut App) {
 50    if cx.has_flag::<NotebookFeatureFlag>() || std::env::var("LOCAL_NOTEBOOK_DEV").is_ok() {
 51        workspace::register_project_item::<NotebookEditor>(cx);
 52    }
 53
 54    cx.observe_flag::<NotebookFeatureFlag, _>({
 55        move |is_enabled, cx| {
 56            if is_enabled {
 57                workspace::register_project_item::<NotebookEditor>(cx);
 58            } else {
 59                // todo: there is no way to unregister a project item, so if the feature flag
 60                // gets turned off they need to restart Zed.
 61            }
 62        }
 63    })
 64    .detach();
 65}
 66
 67pub struct NotebookEditor {
 68    languages: Arc<LanguageRegistry>,
 69    project: Entity<Project>,
 70
 71    focus_handle: FocusHandle,
 72    notebook_item: Entity<NotebookItem>,
 73
 74    remote_id: Option<ViewId>,
 75    cell_list: ListState,
 76
 77    selected_cell_index: usize,
 78    cell_order: Vec<CellId>,
 79    cell_map: HashMap<CellId, Cell>,
 80}
 81
 82impl NotebookEditor {
 83    pub fn new(
 84        project: Entity<Project>,
 85        notebook_item: Entity<NotebookItem>,
 86        window: &mut Window,
 87        cx: &mut Context<Self>,
 88    ) -> Self {
 89        let focus_handle = cx.focus_handle();
 90
 91        let languages = project.read(cx).languages().clone();
 92        let language_name = notebook_item.read(cx).language_name();
 93
 94        let notebook_language = notebook_item.read(cx).notebook_language();
 95        let notebook_language = cx
 96            .spawn_in(window, async move |_, _| notebook_language.await)
 97            .shared();
 98
 99        let mut cell_order = vec![]; // Vec<CellId>
100        let mut cell_map = HashMap::default(); // HashMap<CellId, Cell>
101
102        for (index, cell) in notebook_item
103            .read(cx)
104            .notebook
105            .clone()
106            .cells
107            .iter()
108            .enumerate()
109        {
110            let cell_id = cell.id();
111            cell_order.push(cell_id.clone());
112            cell_map.insert(
113                cell_id.clone(),
114                Cell::load(cell, &languages, notebook_language.clone(), window, cx),
115            );
116        }
117
118        let notebook_handle = cx.entity().downgrade();
119        let cell_count = cell_order.len();
120
121        let this = cx.entity();
122        let cell_list = ListState::new(
123            cell_count,
124            gpui::ListAlignment::Top,
125            px(1000.),
126            move |ix, window, cx| {
127                notebook_handle
128                    .upgrade()
129                    .and_then(|notebook_handle| {
130                        notebook_handle.update(cx, |notebook, cx| {
131                            notebook
132                                .cell_order
133                                .get(ix)
134                                .and_then(|cell_id| notebook.cell_map.get(cell_id))
135                                .map(|cell| {
136                                    notebook
137                                        .render_cell(ix, cell, window, cx)
138                                        .into_any_element()
139                                })
140                        })
141                    })
142                    .unwrap_or_else(|| div().into_any())
143            },
144        );
145
146        Self {
147            project,
148            languages: languages.clone(),
149            focus_handle,
150            notebook_item,
151            remote_id: None,
152            cell_list,
153            selected_cell_index: 0,
154            cell_order: cell_order.clone(),
155            cell_map: cell_map.clone(),
156        }
157    }
158
159    fn has_outputs(&self, window: &mut Window, cx: &mut Context<Self>) -> bool {
160        self.cell_map.values().any(|cell| {
161            if let Cell::Code(code_cell) = cell {
162                code_cell.read(cx).has_outputs()
163            } else {
164                false
165            }
166        })
167    }
168
169    fn clear_outputs(&mut self, window: &mut Window, cx: &mut Context<Self>) {
170        for cell in self.cell_map.values() {
171            if let Cell::Code(code_cell) = cell {
172                code_cell.update(cx, |cell, _cx| {
173                    cell.clear_outputs();
174                });
175            }
176        }
177    }
178
179    fn run_cells(&mut self, window: &mut Window, cx: &mut Context<Self>) {
180        println!("Cells would all run here, if that was implemented!");
181    }
182
183    fn open_notebook(&mut self, _: &OpenNotebook, _window: &mut Window, _cx: &mut Context<Self>) {
184        println!("Open notebook triggered");
185    }
186
187    fn move_cell_up(&mut self, window: &mut Window, cx: &mut Context<Self>) {
188        println!("Move cell up triggered");
189    }
190
191    fn move_cell_down(&mut self, window: &mut Window, cx: &mut Context<Self>) {
192        println!("Move cell down triggered");
193    }
194
195    fn add_markdown_block(&mut self, window: &mut Window, cx: &mut Context<Self>) {
196        println!("Add markdown block triggered");
197    }
198
199    fn add_code_block(&mut self, window: &mut Window, cx: &mut Context<Self>) {
200        println!("Add code block triggered");
201    }
202
203    fn cell_count(&self) -> usize {
204        self.cell_map.len()
205    }
206
207    fn selected_index(&self) -> usize {
208        self.selected_cell_index
209    }
210
211    pub fn set_selected_index(
212        &mut self,
213        index: usize,
214        jump_to_index: bool,
215        window: &mut Window,
216        cx: &mut Context<Self>,
217    ) {
218        // let previous_index = self.selected_cell_index;
219        self.selected_cell_index = index;
220        let current_index = self.selected_cell_index;
221
222        // in the future we may have some `on_cell_change` event that we want to fire here
223
224        if jump_to_index {
225            self.jump_to_cell(current_index, window, cx);
226        }
227    }
228
229    pub fn select_next(
230        &mut self,
231        _: &menu::SelectNext,
232        window: &mut Window,
233        cx: &mut Context<Self>,
234    ) {
235        let count = self.cell_count();
236        if count > 0 {
237            let index = self.selected_index();
238            let ix = if index == count - 1 {
239                count - 1
240            } else {
241                index + 1
242            };
243            self.set_selected_index(ix, true, window, cx);
244            cx.notify();
245        }
246    }
247
248    pub fn select_previous(
249        &mut self,
250        _: &menu::SelectPrevious,
251        window: &mut Window,
252        cx: &mut Context<Self>,
253    ) {
254        let count = self.cell_count();
255        if count > 0 {
256            let index = self.selected_index();
257            let ix = if index == 0 { 0 } else { index - 1 };
258            self.set_selected_index(ix, true, window, cx);
259            cx.notify();
260        }
261    }
262
263    pub fn select_first(
264        &mut self,
265        _: &menu::SelectFirst,
266        window: &mut Window,
267        cx: &mut Context<Self>,
268    ) {
269        let count = self.cell_count();
270        if count > 0 {
271            self.set_selected_index(0, true, window, cx);
272            cx.notify();
273        }
274    }
275
276    pub fn select_last(
277        &mut self,
278        _: &menu::SelectLast,
279        window: &mut Window,
280        cx: &mut Context<Self>,
281    ) {
282        let count = self.cell_count();
283        if count > 0 {
284            self.set_selected_index(count - 1, true, window, cx);
285            cx.notify();
286        }
287    }
288
289    fn jump_to_cell(&mut self, index: usize, _window: &mut Window, _cx: &mut Context<Self>) {
290        self.cell_list.scroll_to_reveal_item(index);
291    }
292
293    fn button_group(window: &mut Window, cx: &mut Context<Self>) -> Div {
294        v_flex()
295            .gap(DynamicSpacing::Base04.rems(cx))
296            .items_center()
297            .w(px(CONTROL_SIZE + 4.0))
298            .overflow_hidden()
299            .rounded(px(5.))
300            .bg(cx.theme().colors().title_bar_background)
301            .p_px()
302            .border_1()
303            .border_color(cx.theme().colors().border)
304    }
305
306    fn render_notebook_control(
307        id: impl Into<SharedString>,
308        icon: IconName,
309        _window: &mut Window,
310        _cx: &mut Context<Self>,
311    ) -> IconButton {
312        let id: ElementId = ElementId::Name(id.into());
313        IconButton::new(id, icon).width(px(CONTROL_SIZE).into())
314    }
315
316    fn render_notebook_controls(
317        &self,
318        window: &mut Window,
319        cx: &mut Context<Self>,
320    ) -> impl IntoElement {
321        let has_outputs = self.has_outputs(window, cx);
322
323        v_flex()
324            .max_w(px(CONTROL_SIZE + 4.0))
325            .items_center()
326            .gap(DynamicSpacing::Base16.rems(cx))
327            .justify_between()
328            .flex_none()
329            .h_full()
330            .py(DynamicSpacing::Base12.px(cx))
331            .child(
332                v_flex()
333                    .gap(DynamicSpacing::Base08.rems(cx))
334                    .child(
335                        Self::button_group(window, cx)
336                            .child(
337                                Self::render_notebook_control(
338                                    "run-all-cells",
339                                    IconName::Play,
340                                    window,
341                                    cx,
342                                )
343                                .tooltip(move |window, cx| {
344                                    Tooltip::for_action("Execute all cells", &RunAll, window, cx)
345                                })
346                                .on_click(|_, window, cx| {
347                                    window.dispatch_action(Box::new(RunAll), cx);
348                                }),
349                            )
350                            .child(
351                                Self::render_notebook_control(
352                                    "clear-all-outputs",
353                                    IconName::ListX,
354                                    window,
355                                    cx,
356                                )
357                                .disabled(!has_outputs)
358                                .tooltip(move |window, cx| {
359                                    Tooltip::for_action(
360                                        "Clear all outputs",
361                                        &ClearOutputs,
362                                        window,
363                                        cx,
364                                    )
365                                })
366                                .on_click(|_, window, cx| {
367                                    window.dispatch_action(Box::new(ClearOutputs), cx);
368                                }),
369                            ),
370                    )
371                    .child(
372                        Self::button_group(window, cx)
373                            .child(
374                                Self::render_notebook_control(
375                                    "move-cell-up",
376                                    IconName::ArrowUp,
377                                    window,
378                                    cx,
379                                )
380                                .tooltip(move |window, cx| {
381                                    Tooltip::for_action("Move cell up", &MoveCellUp, window, cx)
382                                })
383                                .on_click(|_, window, cx| {
384                                    window.dispatch_action(Box::new(MoveCellUp), cx);
385                                }),
386                            )
387                            .child(
388                                Self::render_notebook_control(
389                                    "move-cell-down",
390                                    IconName::ArrowDown,
391                                    window,
392                                    cx,
393                                )
394                                .tooltip(move |window, cx| {
395                                    Tooltip::for_action("Move cell down", &MoveCellDown, window, cx)
396                                })
397                                .on_click(|_, window, cx| {
398                                    window.dispatch_action(Box::new(MoveCellDown), cx);
399                                }),
400                            ),
401                    )
402                    .child(
403                        Self::button_group(window, cx)
404                            .child(
405                                Self::render_notebook_control(
406                                    "new-markdown-cell",
407                                    IconName::Plus,
408                                    window,
409                                    cx,
410                                )
411                                .tooltip(move |window, cx| {
412                                    Tooltip::for_action(
413                                        "Add markdown block",
414                                        &AddMarkdownBlock,
415                                        window,
416                                        cx,
417                                    )
418                                })
419                                .on_click(|_, window, cx| {
420                                    window.dispatch_action(Box::new(AddMarkdownBlock), cx);
421                                }),
422                            )
423                            .child(
424                                Self::render_notebook_control(
425                                    "new-code-cell",
426                                    IconName::Code,
427                                    window,
428                                    cx,
429                                )
430                                .tooltip(move |window, cx| {
431                                    Tooltip::for_action("Add code block", &AddCodeBlock, window, cx)
432                                })
433                                .on_click(|_, window, cx| {
434                                    window.dispatch_action(Box::new(AddCodeBlock), cx);
435                                }),
436                            ),
437                    ),
438            )
439            .child(
440                v_flex()
441                    .gap(DynamicSpacing::Base08.rems(cx))
442                    .items_center()
443                    .child(Self::render_notebook_control(
444                        "more-menu",
445                        IconName::Ellipsis,
446                        window,
447                        cx,
448                    ))
449                    .child(
450                        Self::button_group(window, cx)
451                            .child(IconButton::new("repl", IconName::ReplNeutral)),
452                    ),
453            )
454    }
455
456    fn cell_position(&self, index: usize) -> CellPosition {
457        match index {
458            0 => CellPosition::First,
459            index if index == self.cell_count() - 1 => CellPosition::Last,
460            _ => CellPosition::Middle,
461        }
462    }
463
464    fn render_cell(
465        &self,
466        index: usize,
467        cell: &Cell,
468        window: &mut Window,
469        cx: &mut Context<Self>,
470    ) -> impl IntoElement {
471        let cell_position = self.cell_position(index);
472
473        let is_selected = index == self.selected_cell_index;
474
475        match cell {
476            Cell::Code(cell) => {
477                cell.update(cx, |cell, _cx| {
478                    cell.set_selected(is_selected)
479                        .set_cell_position(cell_position);
480                });
481                cell.clone().into_any_element()
482            }
483            Cell::Markdown(cell) => {
484                cell.update(cx, |cell, _cx| {
485                    cell.set_selected(is_selected)
486                        .set_cell_position(cell_position);
487                });
488                cell.clone().into_any_element()
489            }
490            Cell::Raw(cell) => {
491                cell.update(cx, |cell, _cx| {
492                    cell.set_selected(is_selected)
493                        .set_cell_position(cell_position);
494                });
495                cell.clone().into_any_element()
496            }
497        }
498    }
499}
500
501impl Render for NotebookEditor {
502    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
503        div()
504            .key_context("notebook")
505            .track_focus(&self.focus_handle)
506            .on_action(cx.listener(|this, &OpenNotebook, window, cx| {
507                this.open_notebook(&OpenNotebook, window, cx)
508            }))
509            .on_action(
510                cx.listener(|this, &ClearOutputs, window, cx| this.clear_outputs(window, cx)),
511            )
512            .on_action(cx.listener(|this, &RunAll, window, cx| this.run_cells(window, cx)))
513            .on_action(cx.listener(|this, &MoveCellUp, window, cx| this.move_cell_up(window, cx)))
514            .on_action(
515                cx.listener(|this, &MoveCellDown, window, cx| this.move_cell_down(window, cx)),
516            )
517            .on_action(cx.listener(|this, &AddMarkdownBlock, window, cx| {
518                this.add_markdown_block(window, cx)
519            }))
520            .on_action(
521                cx.listener(|this, &AddCodeBlock, window, cx| this.add_code_block(window, cx)),
522            )
523            .on_action(cx.listener(Self::select_next))
524            .on_action(cx.listener(Self::select_previous))
525            .on_action(cx.listener(Self::select_first))
526            .on_action(cx.listener(Self::select_last))
527            .flex()
528            .items_start()
529            .size_full()
530            .overflow_hidden()
531            .px(DynamicSpacing::Base12.px(cx))
532            .gap(DynamicSpacing::Base12.px(cx))
533            .bg(cx.theme().colors().tab_bar_background)
534            .child(
535                v_flex()
536                    .id("notebook-cells")
537                    .flex_1()
538                    .size_full()
539                    .overflow_y_scroll()
540                    .child(list(self.cell_list.clone()).size_full()),
541            )
542            .child(self.render_notebook_controls(window, cx))
543    }
544}
545
546impl Focusable for NotebookEditor {
547    fn focus_handle(&self, _: &App) -> FocusHandle {
548        self.focus_handle.clone()
549    }
550}
551
552// Intended to be a NotebookBuffer
553pub struct NotebookItem {
554    path: PathBuf,
555    project_path: ProjectPath,
556    languages: Arc<LanguageRegistry>,
557    // Raw notebook data
558    notebook: nbformat::v4::Notebook,
559    // Store our version of the notebook in memory (cell_order, cell_map)
560    id: ProjectEntryId,
561}
562
563impl project::ProjectItem for NotebookItem {
564    fn try_open(
565        project: &Entity<Project>,
566        path: &ProjectPath,
567        cx: &mut App,
568    ) -> Option<Task<anyhow::Result<Entity<Self>>>> {
569        let path = path.clone();
570        let project = project.clone();
571        let fs = project.read(cx).fs().clone();
572        let languages = project.read(cx).languages().clone();
573
574        if path.path.extension().unwrap_or_default() == "ipynb" {
575            Some(cx.spawn(async move |cx| {
576                let abs_path = project
577                    .read_with(cx, |project, cx| project.absolute_path(&path, cx))?
578                    .with_context(|| format!("finding the absolute path of {path:?}"))?;
579
580                // todo: watch for changes to the file
581                let file_content = fs.load(&abs_path.as_path()).await?;
582                let notebook = nbformat::parse_notebook(&file_content);
583
584                let notebook = match notebook {
585                    Ok(nbformat::Notebook::V4(notebook)) => notebook,
586                    // 4.1 - 4.4 are converted to 4.5
587                    Ok(nbformat::Notebook::Legacy(legacy_notebook)) => {
588                        // TODO: Decide if we want to mutate the notebook by including Cell IDs
589                        // and any other conversions
590                        let notebook = nbformat::upgrade_legacy_notebook(legacy_notebook)?;
591                        notebook
592                    }
593                    // Bad notebooks and notebooks v4.0 and below are not supported
594                    Err(e) => {
595                        anyhow::bail!("Failed to parse notebook: {:?}", e);
596                    }
597                };
598
599                let id = project
600                    .update(cx, |project, cx| project.entry_for_path(&path, cx))?
601                    .context("Entry not found")?
602                    .id;
603
604                cx.new(|_| NotebookItem {
605                    path: abs_path,
606                    project_path: path,
607                    languages,
608                    notebook,
609                    id,
610                })
611            }))
612        } else {
613            None
614        }
615    }
616
617    fn entry_id(&self, _: &App) -> Option<ProjectEntryId> {
618        Some(self.id)
619    }
620
621    fn project_path(&self, _: &App) -> Option<ProjectPath> {
622        Some(self.project_path.clone())
623    }
624
625    fn is_dirty(&self) -> bool {
626        false
627    }
628}
629
630impl NotebookItem {
631    pub fn language_name(&self) -> Option<String> {
632        self.notebook
633            .metadata
634            .language_info
635            .as_ref()
636            .map(|l| l.name.clone())
637            .or(self
638                .notebook
639                .metadata
640                .kernelspec
641                .as_ref()
642                .and_then(|spec| spec.language.clone()))
643    }
644
645    pub fn notebook_language(&self) -> impl Future<Output = Option<Arc<Language>>> + use<> {
646        let language_name = self.language_name();
647        let languages = self.languages.clone();
648
649        async move {
650            if let Some(language_name) = language_name {
651                languages.language_for_name(&language_name).await.ok()
652            } else {
653                None
654            }
655        }
656    }
657}
658
659impl EventEmitter<()> for NotebookEditor {}
660
661// pub struct NotebookControls {
662//     pane_focused: bool,
663//     active_item: Option<Box<dyn ItemHandle>>,
664//     // subscription: Option<Subscription>,
665// }
666
667// impl NotebookControls {
668//     pub fn new() -> Self {
669//         Self {
670//             pane_focused: false,
671//             active_item: Default::default(),
672//             // subscription: Default::default(),
673//         }
674//     }
675// }
676
677// impl EventEmitter<ToolbarItemEvent> for NotebookControls {}
678
679// impl Render for NotebookControls {
680//     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
681//         div().child("notebook controls")
682//     }
683// }
684
685// impl ToolbarItemView for NotebookControls {
686//     fn set_active_pane_item(
687//         &mut self,
688//         active_pane_item: Option<&dyn workspace::ItemHandle>,
689//         window: &mut Window, cx: &mut Context<Self>,
690//     ) -> workspace::ToolbarItemLocation {
691//         cx.notify();
692//         self.active_item = None;
693
694//         let Some(item) = active_pane_item else {
695//             return ToolbarItemLocation::Hidden;
696//         };
697
698//         ToolbarItemLocation::PrimaryLeft
699//     }
700
701//     fn pane_focus_update(&mut self, pane_focused: bool, _window: &mut Window, _cx: &mut Context<Self>) {
702//         self.pane_focused = pane_focused;
703//     }
704// }
705
706impl Item for NotebookEditor {
707    type Event = ();
708
709    fn clone_on_split(
710        &self,
711        _workspace_id: Option<workspace::WorkspaceId>,
712        window: &mut Window,
713        cx: &mut Context<Self>,
714    ) -> Option<Entity<Self>>
715    where
716        Self: Sized,
717    {
718        Some(cx.new(|cx| Self::new(self.project.clone(), self.notebook_item.clone(), window, cx)))
719    }
720
721    fn for_each_project_item(
722        &self,
723        cx: &App,
724        f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
725    ) {
726        f(self.notebook_item.entity_id(), self.notebook_item.read(cx))
727    }
728
729    fn is_singleton(&self, _cx: &App) -> bool {
730        true
731    }
732
733    fn tab_content(&self, params: TabContentParams, window: &Window, cx: &App) -> AnyElement {
734        Label::new(self.tab_content_text(params.detail.unwrap_or(0), cx))
735            .single_line()
736            .color(params.text_color())
737            .when(params.preview, |this| this.italic())
738            .into_any_element()
739    }
740
741    fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
742        let path = &self.notebook_item.read(cx).path;
743        let title = path
744            .file_name()
745            .unwrap_or_else(|| path.as_os_str())
746            .to_string_lossy()
747            .to_string();
748        title.into()
749    }
750
751    fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
752        Some(IconName::Book.into())
753    }
754
755    fn show_toolbar(&self) -> bool {
756        false
757    }
758
759    // TODO
760    fn pixel_position_of_cursor(&self, _: &App) -> Option<Point<Pixels>> {
761        None
762    }
763
764    // TODO
765    fn as_searchable(&self, _: &Entity<Self>) -> Option<Box<dyn SearchableItemHandle>> {
766        None
767    }
768
769    fn set_nav_history(
770        &mut self,
771        _: workspace::ItemNavHistory,
772        _window: &mut Window,
773        _: &mut Context<Self>,
774    ) {
775        // TODO
776    }
777
778    // TODO
779    fn can_save(&self, _cx: &App) -> bool {
780        false
781    }
782    // TODO
783    fn save(
784        &mut self,
785        _format: bool,
786        _project: Entity<Project>,
787        _window: &mut Window,
788        _cx: &mut Context<Self>,
789    ) -> Task<Result<()>> {
790        unimplemented!("save() must be implemented if can_save() returns true")
791    }
792
793    // TODO
794    fn save_as(
795        &mut self,
796        _project: Entity<Project>,
797        _path: ProjectPath,
798        _window: &mut Window,
799        _cx: &mut Context<Self>,
800    ) -> Task<Result<()>> {
801        unimplemented!("save_as() must be implemented if can_save() returns true")
802    }
803    // TODO
804    fn reload(
805        &mut self,
806        _project: Entity<Project>,
807        _window: &mut Window,
808        _cx: &mut Context<Self>,
809    ) -> Task<Result<()>> {
810        unimplemented!("reload() must be implemented if can_save() returns true")
811    }
812
813    fn is_dirty(&self, cx: &App) -> bool {
814        self.cell_map.values().any(|cell| {
815            if let Cell::Code(code_cell) = cell {
816                code_cell.read(cx).is_dirty(cx)
817            } else {
818                false
819            }
820        })
821    }
822}
823
824// TODO: Implement this to allow us to persist to the database, etc:
825// impl SerializableItem for NotebookEditor {}
826
827impl ProjectItem for NotebookEditor {
828    type Item = NotebookItem;
829
830    fn for_project_item(
831        project: Entity<Project>,
832        _: Option<&Pane>,
833        item: Entity<Self::Item>,
834        window: &mut Window,
835        cx: &mut Context<Self>,
836    ) -> Self
837    where
838        Self: Sized,
839    {
840        Self::new(project, item, window, cx)
841    }
842}