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}