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