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}