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