notebook_ui.rs

  1#![allow(unused, dead_code)]
  2use std::{path::PathBuf, sync::Arc};
  3
  4use client::proto::ViewId;
  5use collections::HashMap;
  6use feature_flags::{FeatureFlagAppExt as _, NotebookFeatureFlag};
  7use futures::FutureExt;
  8use gpui::{
  9    actions, list, prelude::*, AppContext, EventEmitter, FocusHandle, FocusableView,
 10    ListScrollEvent, ListState, Model, Task,
 11};
 12use language::LanguageRegistry;
 13use project::{Project, ProjectEntryId, ProjectPath};
 14use ui::{prelude::*, Tooltip};
 15use workspace::item::ItemEvent;
 16use workspace::{Item, ItemHandle, ProjectItem, ToolbarItemLocation};
 17use workspace::{ToolbarItemEvent, ToolbarItemView};
 18
 19use super::{Cell, CellPosition, RenderableCell};
 20
 21use nbformat::v4::CellId;
 22use nbformat::v4::Metadata as NotebookMetadata;
 23
 24pub(crate) const DEFAULT_NOTEBOOK_FORMAT: i32 = 4;
 25pub(crate) const DEFAULT_NOTEBOOK_FORMAT_MINOR: i32 = 0;
 26
 27actions!(
 28    notebook,
 29    [
 30        OpenNotebook,
 31        RunAll,
 32        ClearOutputs,
 33        MoveCellUp,
 34        MoveCellDown,
 35        AddMarkdownBlock,
 36        AddCodeBlock,
 37    ]
 38);
 39
 40pub(crate) const MAX_TEXT_BLOCK_WIDTH: f32 = 9999.0;
 41pub(crate) const SMALL_SPACING_SIZE: f32 = 8.0;
 42pub(crate) const MEDIUM_SPACING_SIZE: f32 = 12.0;
 43pub(crate) const LARGE_SPACING_SIZE: f32 = 16.0;
 44pub(crate) const GUTTER_WIDTH: f32 = 19.0;
 45pub(crate) const CODE_BLOCK_INSET: f32 = MEDIUM_SPACING_SIZE;
 46pub(crate) const CONTROL_SIZE: f32 = 20.0;
 47
 48pub fn init(cx: &mut AppContext) {
 49    if cx.has_flag::<NotebookFeatureFlag>() || std::env::var("LOCAL_NOTEBOOK_DEV").is_ok() {
 50        workspace::register_project_item::<NotebookEditor>(cx);
 51    }
 52
 53    cx.observe_flag::<NotebookFeatureFlag, _>({
 54        move |is_enabled, cx| {
 55            if is_enabled {
 56                workspace::register_project_item::<NotebookEditor>(cx);
 57            } else {
 58                // todo: there is no way to unregister a project item, so if the feature flag
 59                // gets turned off they need to restart Zed.
 60            }
 61        }
 62    })
 63    .detach();
 64}
 65
 66pub struct NotebookEditor {
 67    languages: Arc<LanguageRegistry>,
 68
 69    focus_handle: FocusHandle,
 70    project: Model<Project>,
 71    path: ProjectPath,
 72
 73    remote_id: Option<ViewId>,
 74    cell_list: ListState,
 75
 76    metadata: NotebookMetadata,
 77    nbformat: i32,
 78    nbformat_minor: i32,
 79    selected_cell_index: usize,
 80    cell_order: Vec<CellId>,
 81    cell_map: HashMap<CellId, Cell>,
 82}
 83
 84impl NotebookEditor {
 85    pub fn new(
 86        project: Model<Project>,
 87        notebook_item: Model<NotebookItem>,
 88        cx: &mut ViewContext<Self>,
 89    ) -> Self {
 90        let focus_handle = cx.focus_handle();
 91
 92        let notebook = notebook_item.read(cx).notebook.clone();
 93
 94        let languages = project.read(cx).languages().clone();
 95
 96        let metadata = notebook.metadata;
 97        let nbformat = notebook.nbformat;
 98        let nbformat_minor = notebook.nbformat_minor;
 99
100        let language_name = metadata
101            .language_info
102            .as_ref()
103            .map(|l| l.name.clone())
104            .or(metadata
105                .kernelspec
106                .as_ref()
107                .and_then(|spec| spec.language.clone()));
108
109        let notebook_language = if let Some(language_name) = language_name {
110            cx.spawn(|_, _| {
111                let languages = languages.clone();
112                async move { languages.language_for_name(&language_name).await.ok() }
113            })
114            .shared()
115        } else {
116            Task::ready(None).shared()
117        };
118
119        let languages = project.read(cx).languages().clone();
120        let notebook_language = cx
121            .spawn(|_, _| {
122                // todo: pull from notebook metadata
123                const TODO: &'static str = "Python";
124                let languages = languages.clone();
125                async move { languages.language_for_name(TODO).await.ok() }
126            })
127            .shared();
128
129        let mut cell_order = vec![];
130        let mut cell_map = HashMap::default();
131
132        for (index, cell) in notebook.cells.iter().enumerate() {
133            let cell_id = cell.id();
134            cell_order.push(cell_id.clone());
135            cell_map.insert(
136                cell_id.clone(),
137                Cell::load(cell, &languages, notebook_language.clone(), cx),
138            );
139        }
140
141        let view = cx.view().downgrade();
142        let cell_count = cell_order.len();
143        let cell_order_for_list = cell_order.clone();
144        let cell_map_for_list = cell_map.clone();
145
146        let cell_list = ListState::new(
147            cell_count,
148            gpui::ListAlignment::Top,
149            // TODO: This is a totally random number,
150            // not sure what this should be
151            px(3000.),
152            move |ix, cx| {
153                let cell_order_for_list = cell_order_for_list.clone();
154                let cell_id = cell_order_for_list[ix].clone();
155                if let Some(view) = view.upgrade() {
156                    let cell_id = cell_id.clone();
157                    if let Some(cell) = cell_map_for_list.clone().get(&cell_id) {
158                        view.update(cx, |view, cx| {
159                            view.render_cell(ix, cell, cx).into_any_element()
160                        })
161                    } else {
162                        div().into_any()
163                    }
164                } else {
165                    div().into_any()
166                }
167            },
168        );
169
170        Self {
171            languages: languages.clone(),
172            focus_handle,
173            project,
174            path: notebook_item.read(cx).project_path.clone(),
175            remote_id: None,
176            cell_list,
177            selected_cell_index: 0,
178            metadata,
179            nbformat,
180            nbformat_minor,
181            cell_order: cell_order.clone(),
182            cell_map: cell_map.clone(),
183        }
184    }
185
186    fn has_outputs(&self, cx: &ViewContext<Self>) -> bool {
187        self.cell_map.values().any(|cell| {
188            if let Cell::Code(code_cell) = cell {
189                code_cell.read(cx).has_outputs()
190            } else {
191                false
192            }
193        })
194    }
195
196    fn is_dirty(&self, cx: &AppContext) -> bool {
197        self.cell_map.values().any(|cell| {
198            if let Cell::Code(code_cell) = cell {
199                code_cell.read(cx).is_dirty(cx)
200            } else {
201                false
202            }
203        })
204    }
205
206    fn clear_outputs(&mut self, cx: &mut ViewContext<Self>) {
207        for cell in self.cell_map.values() {
208            if let Cell::Code(code_cell) = cell {
209                code_cell.update(cx, |cell, _cx| {
210                    cell.clear_outputs();
211                });
212            }
213        }
214    }
215
216    fn run_cells(&mut self, cx: &mut ViewContext<Self>) {
217        println!("Cells would all run here, if that was implemented!");
218    }
219
220    fn open_notebook(&mut self, _: &OpenNotebook, _cx: &mut ViewContext<Self>) {
221        println!("Open notebook triggered");
222    }
223
224    fn move_cell_up(&mut self, cx: &mut ViewContext<Self>) {
225        println!("Move cell up triggered");
226    }
227
228    fn move_cell_down(&mut self, cx: &mut ViewContext<Self>) {
229        println!("Move cell down triggered");
230    }
231
232    fn add_markdown_block(&mut self, cx: &mut ViewContext<Self>) {
233        println!("Add markdown block triggered");
234    }
235
236    fn add_code_block(&mut self, cx: &mut ViewContext<Self>) {
237        println!("Add code block triggered");
238    }
239
240    fn cell_count(&self) -> usize {
241        self.cell_map.len()
242    }
243
244    fn selected_index(&self) -> usize {
245        self.selected_cell_index
246    }
247
248    pub fn set_selected_index(
249        &mut self,
250        index: usize,
251        jump_to_index: bool,
252        cx: &mut ViewContext<Self>,
253    ) {
254        // let previous_index = self.selected_cell_index;
255        self.selected_cell_index = index;
256        let current_index = self.selected_cell_index;
257
258        // in the future we may have some `on_cell_change` event that we want to fire here
259
260        if jump_to_index {
261            self.jump_to_cell(current_index, cx);
262        }
263    }
264
265    pub fn select_next(&mut self, _: &menu::SelectNext, cx: &mut ViewContext<Self>) {
266        let count = self.cell_count();
267        if count > 0 {
268            let index = self.selected_index();
269            let ix = if index == count - 1 {
270                count - 1
271            } else {
272                index + 1
273            };
274            self.set_selected_index(ix, true, cx);
275            cx.notify();
276        }
277    }
278
279    pub fn select_previous(&mut self, _: &menu::SelectPrev, cx: &mut ViewContext<Self>) {
280        let count = self.cell_count();
281        if count > 0 {
282            let index = self.selected_index();
283            let ix = if index == 0 { 0 } else { index - 1 };
284            self.set_selected_index(ix, true, cx);
285            cx.notify();
286        }
287    }
288
289    pub fn select_first(&mut self, _: &menu::SelectFirst, cx: &mut ViewContext<Self>) {
290        let count = self.cell_count();
291        if count > 0 {
292            self.set_selected_index(0, true, cx);
293            cx.notify();
294        }
295    }
296
297    pub fn select_last(&mut self, _: &menu::SelectLast, cx: &mut ViewContext<Self>) {
298        let count = self.cell_count();
299        if count > 0 {
300            self.set_selected_index(count - 1, true, cx);
301            cx.notify();
302        }
303    }
304
305    fn jump_to_cell(&mut self, index: usize, _cx: &mut ViewContext<Self>) {
306        self.cell_list.scroll_to_reveal_item(index);
307    }
308
309    fn button_group(cx: &ViewContext<Self>) -> Div {
310        v_flex()
311            .gap(Spacing::Small.rems(cx))
312            .items_center()
313            .w(px(CONTROL_SIZE + 4.0))
314            .overflow_hidden()
315            .rounded(px(5.))
316            .bg(cx.theme().colors().title_bar_background)
317            .p_px()
318            .border_1()
319            .border_color(cx.theme().colors().border)
320    }
321
322    fn render_notebook_control(
323        id: impl Into<SharedString>,
324        icon: IconName,
325        _cx: &ViewContext<Self>,
326    ) -> IconButton {
327        let id: ElementId = ElementId::Name(id.into());
328        IconButton::new(id, icon).width(px(CONTROL_SIZE).into())
329    }
330
331    fn render_notebook_controls(&self, cx: &ViewContext<Self>) -> impl IntoElement {
332        let has_outputs = self.has_outputs(cx);
333
334        v_flex()
335            .max_w(px(CONTROL_SIZE + 4.0))
336            .items_center()
337            .gap(Spacing::XXLarge.rems(cx))
338            .justify_between()
339            .flex_none()
340            .h_full()
341            .py(Spacing::XLarge.px(cx))
342            .child(
343                v_flex()
344                    .gap(Spacing::Large.rems(cx))
345                    .child(
346                        Self::button_group(cx)
347                            .child(
348                                Self::render_notebook_control("run-all-cells", IconName::Play, cx)
349                                    .tooltip(move |cx| {
350                                        Tooltip::for_action("Execute all cells", &RunAll, cx)
351                                    })
352                                    .on_click(|_, cx| {
353                                        cx.dispatch_action(Box::new(RunAll));
354                                    }),
355                            )
356                            .child(
357                                Self::render_notebook_control(
358                                    "clear-all-outputs",
359                                    IconName::ListX,
360                                    cx,
361                                )
362                                .disabled(!has_outputs)
363                                .tooltip(move |cx| {
364                                    Tooltip::for_action("Clear all outputs", &ClearOutputs, cx)
365                                })
366                                .on_click(|_, cx| {
367                                    cx.dispatch_action(Box::new(ClearOutputs));
368                                }),
369                            ),
370                    )
371                    .child(
372                        Self::button_group(cx)
373                            .child(
374                                Self::render_notebook_control(
375                                    "move-cell-up",
376                                    IconName::ArrowUp,
377                                    cx,
378                                )
379                                .tooltip(move |cx| {
380                                    Tooltip::for_action("Move cell up", &MoveCellUp, cx)
381                                })
382                                .on_click(|_, cx| {
383                                    cx.dispatch_action(Box::new(MoveCellUp));
384                                }),
385                            )
386                            .child(
387                                Self::render_notebook_control(
388                                    "move-cell-down",
389                                    IconName::ArrowDown,
390                                    cx,
391                                )
392                                .tooltip(move |cx| {
393                                    Tooltip::for_action("Move cell down", &MoveCellDown, cx)
394                                })
395                                .on_click(|_, cx| {
396                                    cx.dispatch_action(Box::new(MoveCellDown));
397                                }),
398                            ),
399                    )
400                    .child(
401                        Self::button_group(cx)
402                            .child(
403                                Self::render_notebook_control(
404                                    "new-markdown-cell",
405                                    IconName::Plus,
406                                    cx,
407                                )
408                                .tooltip(move |cx| {
409                                    Tooltip::for_action("Add markdown block", &AddMarkdownBlock, cx)
410                                })
411                                .on_click(|_, cx| {
412                                    cx.dispatch_action(Box::new(AddMarkdownBlock));
413                                }),
414                            )
415                            .child(
416                                Self::render_notebook_control("new-code-cell", IconName::Code, cx)
417                                    .tooltip(move |cx| {
418                                        Tooltip::for_action("Add code block", &AddCodeBlock, cx)
419                                    })
420                                    .on_click(|_, cx| {
421                                        cx.dispatch_action(Box::new(AddCodeBlock));
422                                    }),
423                            ),
424                    ),
425            )
426            .child(
427                v_flex()
428                    .gap(Spacing::Large.rems(cx))
429                    .items_center()
430                    .child(Self::render_notebook_control(
431                        "more-menu",
432                        IconName::Ellipsis,
433                        cx,
434                    ))
435                    .child(
436                        Self::button_group(cx)
437                            .child(IconButton::new("repl", IconName::ReplNeutral)),
438                    ),
439            )
440    }
441
442    fn cell_position(&self, index: usize) -> CellPosition {
443        match index {
444            0 => CellPosition::First,
445            index if index == self.cell_count() - 1 => CellPosition::Last,
446            _ => CellPosition::Middle,
447        }
448    }
449
450    fn render_cell(
451        &self,
452        index: usize,
453        cell: &Cell,
454        cx: &mut ViewContext<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, cx: &mut ViewContext<Self>) -> impl IntoElement {
488        div()
489            .key_context("notebook")
490            .track_focus(&self.focus_handle)
491            .on_action(cx.listener(|this, &OpenNotebook, cx| this.open_notebook(&OpenNotebook, cx)))
492            .on_action(cx.listener(|this, &ClearOutputs, cx| this.clear_outputs(cx)))
493            .on_action(cx.listener(|this, &RunAll, cx| this.run_cells(cx)))
494            .on_action(cx.listener(|this, &MoveCellUp, cx| this.move_cell_up(cx)))
495            .on_action(cx.listener(|this, &MoveCellDown, cx| this.move_cell_down(cx)))
496            .on_action(cx.listener(|this, &AddMarkdownBlock, cx| this.add_markdown_block(cx)))
497            .on_action(cx.listener(|this, &AddCodeBlock, cx| this.add_code_block(cx)))
498            .on_action(cx.listener(Self::select_next))
499            .on_action(cx.listener(Self::select_previous))
500            .on_action(cx.listener(Self::select_first))
501            .on_action(cx.listener(Self::select_last))
502            .flex()
503            .items_start()
504            .size_full()
505            .overflow_hidden()
506            .px(Spacing::XLarge.px(cx))
507            .gap(Spacing::XLarge.px(cx))
508            .bg(cx.theme().colors().tab_bar_background)
509            .child(
510                v_flex()
511                    .id("notebook-cells")
512                    .flex_1()
513                    .size_full()
514                    .overflow_y_scroll()
515                    .child(list(self.cell_list.clone()).size_full()),
516            )
517            .child(self.render_notebook_controls(cx))
518    }
519}
520
521impl FocusableView for NotebookEditor {
522    fn focus_handle(&self, _: &AppContext) -> FocusHandle {
523        self.focus_handle.clone()
524    }
525}
526
527pub struct NotebookItem {
528    path: PathBuf,
529    project_path: ProjectPath,
530    notebook: nbformat::v4::Notebook,
531}
532
533impl project::Item for NotebookItem {
534    fn try_open(
535        project: &Model<Project>,
536        path: &ProjectPath,
537        cx: &mut AppContext,
538    ) -> Option<Task<gpui::Result<Model<Self>>>> {
539        let path = path.clone();
540        let project = project.clone();
541
542        if path.path.extension().unwrap_or_default() == "ipynb" {
543            Some(cx.spawn(|mut cx| async move {
544                let abs_path = project
545                    .read_with(&cx, |project, cx| project.absolute_path(&path, cx))?
546                    .ok_or_else(|| anyhow::anyhow!("Failed to find the absolute path"))?;
547
548                let file_content = std::fs::read_to_string(abs_path.clone())?;
549                let notebook = nbformat::parse_notebook(&file_content);
550
551                let notebook = match notebook {
552                    Ok(nbformat::Notebook::V4(notebook)) => notebook,
553                    Ok(nbformat::Notebook::Legacy(legacy_notebook)) => {
554                        // todo!(): Decide if we want to mutate the notebook by including Cell IDs
555                        // and any other conversions
556                        let notebook = nbformat::upgrade_legacy_notebook(legacy_notebook)?;
557                        notebook
558                    }
559                    Err(e) => {
560                        anyhow::bail!("Failed to parse notebook: {:?}", e);
561                    }
562                };
563
564                cx.new_model(|_| NotebookItem {
565                    path: abs_path,
566                    project_path: path,
567                    notebook,
568                })
569            }))
570        } else {
571            None
572        }
573    }
574
575    fn entry_id(&self, _: &AppContext) -> Option<ProjectEntryId> {
576        None
577    }
578
579    fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
580        Some(self.project_path.clone())
581    }
582}
583
584impl EventEmitter<()> for NotebookEditor {}
585
586// pub struct NotebookControls {
587//     pane_focused: bool,
588//     active_item: Option<Box<dyn ItemHandle>>,
589//     // subscription: Option<Subscription>,
590// }
591
592// impl NotebookControls {
593//     pub fn new() -> Self {
594//         Self {
595//             pane_focused: false,
596//             active_item: Default::default(),
597//             // subscription: Default::default(),
598//         }
599//     }
600// }
601
602// impl EventEmitter<ToolbarItemEvent> for NotebookControls {}
603
604// impl Render for NotebookControls {
605//     fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
606//         div().child("notebook controls")
607//     }
608// }
609
610// impl ToolbarItemView for NotebookControls {
611//     fn set_active_pane_item(
612//         &mut self,
613//         active_pane_item: Option<&dyn workspace::ItemHandle>,
614//         cx: &mut ViewContext<Self>,
615//     ) -> workspace::ToolbarItemLocation {
616//         cx.notify();
617//         self.active_item = None;
618
619//         let Some(item) = active_pane_item else {
620//             return ToolbarItemLocation::Hidden;
621//         };
622
623//         ToolbarItemLocation::PrimaryLeft
624//     }
625
626//     fn pane_focus_update(&mut self, pane_focused: bool, _: &mut ViewContext<Self>) {
627//         self.pane_focused = pane_focused;
628//     }
629// }
630
631impl Item for NotebookEditor {
632    type Event = ();
633
634    fn tab_content_text(&self, _cx: &WindowContext) -> Option<SharedString> {
635        let path = self.path.path.clone();
636
637        path.file_stem()
638            .map(|stem| stem.to_string_lossy().into_owned())
639            .map(SharedString::from)
640    }
641
642    fn tab_icon(&self, _cx: &ui::WindowContext) -> Option<Icon> {
643        Some(IconName::Book.into())
644    }
645
646    fn show_toolbar(&self) -> bool {
647        false
648    }
649
650    fn is_dirty(&self, cx: &AppContext) -> bool {
651        // self.is_dirty(cx)
652        false
653    }
654}
655
656// TODO: Implement this to allow us to persist to the database, etc:
657// impl SerializableItem for NotebookEditor {}
658
659impl ProjectItem for NotebookEditor {
660    type Item = NotebookItem;
661
662    fn for_project_item(
663        project: Model<Project>,
664        item: Model<Self::Item>,
665        cx: &mut ViewContext<Self>,
666    ) -> Self
667    where
668        Self: Sized,
669    {
670        Self::new(project, item, cx)
671    }
672}