1use gpui::AppContext;
2use gpui::Entity;
3use gpui::Task;
4use picker::Picker;
5use picker::PickerDelegate;
6use settings::RegisterSetting;
7use settings::Settings;
8use std::collections::HashMap;
9use std::collections::HashSet;
10use std::fmt::Debug;
11use std::fmt::Display;
12use std::sync::Arc;
13use ui::ActiveTheme;
14use ui::Button;
15use ui::Clickable;
16use ui::FluentBuilder;
17use ui::KeyBinding;
18use ui::StatefulInteractiveElement;
19use ui::Switch;
20use ui::ToggleState;
21use ui::Tooltip;
22use ui::h_flex;
23use ui::rems_from_px;
24use ui::v_flex;
25
26use gpui::{Action, DismissEvent, EventEmitter, FocusHandle, Focusable, RenderOnce, WeakEntity};
27use serde::Deserialize;
28use ui::{
29 AnyElement, App, Color, CommonAnimationExt, Context, Headline, HeadlineSize, Icon, IconName,
30 InteractiveElement, IntoElement, Label, ListItem, ListSeparator, ModalHeader, Navigable,
31 NavigableEntry, ParentElement, Render, Styled, StyledExt, Toggleable, Window, div, rems,
32};
33use util::ResultExt;
34use util::rel_path::RelPath;
35use workspace::{ModalView, Workspace, with_active_or_new_workspace};
36
37use futures::AsyncReadExt;
38use http::Request;
39use http_client::{AsyncBody, HttpClient};
40
41mod devcontainer_api;
42
43use devcontainer_api::read_devcontainer_configuration_for_project;
44
45use crate::devcontainer_api::DevContainerError;
46use crate::devcontainer_api::apply_dev_container_template;
47
48pub use devcontainer_api::start_dev_container;
49
50#[derive(RegisterSetting)]
51struct DevContainerSettings {
52 use_podman: bool,
53}
54
55impl Settings for DevContainerSettings {
56 fn from_settings(content: &settings::SettingsContent) -> Self {
57 Self {
58 use_podman: content.remote.use_podman.unwrap_or(false),
59 }
60 }
61}
62
63#[derive(PartialEq, Clone, Deserialize, Default, Action)]
64#[action(namespace = projects)]
65#[serde(deny_unknown_fields)]
66struct InitializeDevContainer;
67
68pub fn init(cx: &mut App) {
69 cx.on_action(|_: &InitializeDevContainer, cx| {
70 with_active_or_new_workspace(cx, move |workspace, window, cx| {
71 let weak_entity = cx.weak_entity();
72 workspace.toggle_modal(window, cx, |window, cx| {
73 DevContainerModal::new(weak_entity, window, cx)
74 });
75 });
76 });
77}
78
79#[derive(Clone)]
80struct TemplateEntry {
81 template: DevContainerTemplate,
82 options_selected: HashMap<String, String>,
83 current_option_index: usize,
84 current_option: Option<TemplateOptionSelection>,
85 features_selected: HashSet<DevContainerFeature>,
86}
87
88#[derive(Clone)]
89struct FeatureEntry {
90 feature: DevContainerFeature,
91 toggle_state: ToggleState,
92}
93
94#[derive(Clone)]
95struct TemplateOptionSelection {
96 option_name: String,
97 description: String,
98 navigable_options: Vec<(String, NavigableEntry)>,
99}
100
101impl Eq for TemplateEntry {}
102impl PartialEq for TemplateEntry {
103 fn eq(&self, other: &Self) -> bool {
104 self.template == other.template
105 }
106}
107impl Debug for TemplateEntry {
108 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109 f.debug_struct("TemplateEntry")
110 .field("template", &self.template)
111 .finish()
112 }
113}
114
115impl Eq for FeatureEntry {}
116impl PartialEq for FeatureEntry {
117 fn eq(&self, other: &Self) -> bool {
118 self.feature == other.feature
119 }
120}
121
122impl Debug for FeatureEntry {
123 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124 f.debug_struct("FeatureEntry")
125 .field("feature", &self.feature)
126 .finish()
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
131enum DevContainerState {
132 Initial,
133 QueryingTemplates,
134 TemplateQueryReturned(Result<Vec<TemplateEntry>, String>),
135 QueryingFeatures(TemplateEntry),
136 FeaturesQueryReturned(TemplateEntry),
137 UserOptionsSpecifying(TemplateEntry),
138 ConfirmingWriteDevContainer(TemplateEntry),
139 TemplateWriteFailed(DevContainerError),
140}
141
142#[derive(Debug, Clone)]
143enum DevContainerMessage {
144 SearchTemplates,
145 TemplatesRetrieved(Vec<DevContainerTemplate>),
146 ErrorRetrievingTemplates(String),
147 TemplateSelected(TemplateEntry),
148 TemplateOptionsSpecified(TemplateEntry),
149 TemplateOptionsCompleted(TemplateEntry),
150 FeaturesRetrieved(Vec<DevContainerFeature>),
151 FeaturesSelected(TemplateEntry),
152 NeedConfirmWriteDevContainer(TemplateEntry),
153 ConfirmWriteDevContainer(TemplateEntry),
154 FailedToWriteTemplate(DevContainerError),
155 GoBack,
156}
157
158struct DevContainerModal {
159 workspace: WeakEntity<Workspace>,
160 picker: Option<Entity<Picker<TemplatePickerDelegate>>>,
161 features_picker: Option<Entity<Picker<FeaturePickerDelegate>>>,
162 focus_handle: FocusHandle,
163 confirm_entry: NavigableEntry,
164 back_entry: NavigableEntry,
165 state: DevContainerState,
166}
167
168struct TemplatePickerDelegate {
169 selected_index: usize,
170 placeholder_text: String,
171 stateful_modal: WeakEntity<DevContainerModal>,
172 candidate_templates: Vec<TemplateEntry>,
173 matching_indices: Vec<usize>,
174 on_confirm: Box<
175 dyn FnMut(
176 TemplateEntry,
177 &mut DevContainerModal,
178 &mut Window,
179 &mut Context<DevContainerModal>,
180 ),
181 >,
182}
183
184impl TemplatePickerDelegate {
185 fn new(
186 placeholder_text: String,
187 stateful_modal: WeakEntity<DevContainerModal>,
188 elements: Vec<TemplateEntry>,
189 on_confirm: Box<
190 dyn FnMut(
191 TemplateEntry,
192 &mut DevContainerModal,
193 &mut Window,
194 &mut Context<DevContainerModal>,
195 ),
196 >,
197 ) -> Self {
198 Self {
199 selected_index: 0,
200 placeholder_text,
201 stateful_modal,
202 candidate_templates: elements,
203 matching_indices: Vec::new(),
204 on_confirm,
205 }
206 }
207}
208
209impl PickerDelegate for TemplatePickerDelegate {
210 type ListItem = AnyElement;
211
212 fn match_count(&self) -> usize {
213 self.matching_indices.len()
214 }
215
216 fn selected_index(&self) -> usize {
217 self.selected_index
218 }
219
220 fn set_selected_index(
221 &mut self,
222 ix: usize,
223 _window: &mut Window,
224 _cx: &mut Context<picker::Picker<Self>>,
225 ) {
226 self.selected_index = ix;
227 }
228
229 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
230 self.placeholder_text.clone().into()
231 }
232
233 fn update_matches(
234 &mut self,
235 query: String,
236 _window: &mut Window,
237 _cx: &mut Context<picker::Picker<Self>>,
238 ) -> gpui::Task<()> {
239 self.matching_indices = self
240 .candidate_templates
241 .iter()
242 .enumerate()
243 .filter(|(_, template_entry)| {
244 template_entry
245 .template
246 .id
247 .to_lowercase()
248 .contains(&query.to_lowercase())
249 || template_entry
250 .template
251 .name
252 .to_lowercase()
253 .contains(&query.to_lowercase())
254 })
255 .map(|(ix, _)| ix)
256 .collect();
257
258 self.selected_index = std::cmp::min(
259 self.selected_index,
260 self.matching_indices.len().saturating_sub(1),
261 );
262 Task::ready(())
263 }
264
265 fn confirm(
266 &mut self,
267 _secondary: bool,
268 window: &mut Window,
269 cx: &mut Context<picker::Picker<Self>>,
270 ) {
271 let fun = &mut self.on_confirm;
272
273 self.stateful_modal
274 .update(cx, |modal, cx| {
275 fun(
276 self.candidate_templates[self.matching_indices[self.selected_index]].clone(),
277 modal,
278 window,
279 cx,
280 );
281 })
282 .log_err();
283 }
284
285 fn dismissed(&mut self, window: &mut Window, cx: &mut Context<picker::Picker<Self>>) {
286 self.stateful_modal
287 .update(cx, |modal, cx| {
288 modal.dismiss(&menu::Cancel, window, cx);
289 })
290 .log_err();
291 }
292
293 fn render_match(
294 &self,
295 ix: usize,
296 selected: bool,
297 _window: &mut Window,
298 _cx: &mut Context<picker::Picker<Self>>,
299 ) -> Option<Self::ListItem> {
300 let Some(template_entry) = self.candidate_templates.get(self.matching_indices[ix]) else {
301 return None;
302 };
303 Some(
304 ListItem::new("li-template-match")
305 .inset(true)
306 .spacing(ui::ListItemSpacing::Sparse)
307 .start_slot(Icon::new(IconName::Box))
308 .toggle_state(selected)
309 .child(Label::new(template_entry.template.name.clone()))
310 .into_any_element(),
311 )
312 }
313
314 fn render_footer(
315 &self,
316 _window: &mut Window,
317 cx: &mut Context<Picker<Self>>,
318 ) -> Option<AnyElement> {
319 Some(
320 h_flex()
321 .w_full()
322 .p_1p5()
323 .gap_1()
324 .justify_start()
325 .border_t_1()
326 .border_color(cx.theme().colors().border_variant)
327 .child(
328 Button::new("run-action", "Continue")
329 .key_binding(
330 KeyBinding::for_action(&menu::Confirm, cx)
331 .map(|kb| kb.size(rems_from_px(12.))),
332 )
333 .on_click(|_, window, cx| {
334 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
335 }),
336 )
337 .into_any_element(),
338 )
339 }
340}
341
342struct FeaturePickerDelegate {
343 selected_index: usize,
344 placeholder_text: String,
345 stateful_modal: WeakEntity<DevContainerModal>,
346 candidate_features: Vec<FeatureEntry>,
347 template_entry: TemplateEntry,
348 matching_indices: Vec<usize>,
349 on_confirm: Box<
350 dyn FnMut(
351 TemplateEntry,
352 &mut DevContainerModal,
353 &mut Window,
354 &mut Context<DevContainerModal>,
355 ),
356 >,
357}
358
359impl FeaturePickerDelegate {
360 fn new(
361 placeholder_text: String,
362 stateful_modal: WeakEntity<DevContainerModal>,
363 candidate_features: Vec<FeatureEntry>,
364 template_entry: TemplateEntry,
365 on_confirm: Box<
366 dyn FnMut(
367 TemplateEntry,
368 &mut DevContainerModal,
369 &mut Window,
370 &mut Context<DevContainerModal>,
371 ),
372 >,
373 ) -> Self {
374 Self {
375 selected_index: 0,
376 placeholder_text,
377 stateful_modal,
378 candidate_features,
379 template_entry,
380 matching_indices: Vec::new(),
381 on_confirm,
382 }
383 }
384}
385
386impl PickerDelegate for FeaturePickerDelegate {
387 type ListItem = AnyElement;
388
389 fn match_count(&self) -> usize {
390 self.matching_indices.len()
391 }
392
393 fn selected_index(&self) -> usize {
394 self.selected_index
395 }
396
397 fn set_selected_index(
398 &mut self,
399 ix: usize,
400 _window: &mut Window,
401 _cx: &mut Context<Picker<Self>>,
402 ) {
403 self.selected_index = ix;
404 }
405
406 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
407 self.placeholder_text.clone().into()
408 }
409
410 fn update_matches(
411 &mut self,
412 query: String,
413 _window: &mut Window,
414 _cx: &mut Context<Picker<Self>>,
415 ) -> Task<()> {
416 self.matching_indices = self
417 .candidate_features
418 .iter()
419 .enumerate()
420 .filter(|(_, feature_entry)| {
421 feature_entry
422 .feature
423 .id
424 .to_lowercase()
425 .contains(&query.to_lowercase())
426 || feature_entry
427 .feature
428 .name
429 .to_lowercase()
430 .contains(&query.to_lowercase())
431 })
432 .map(|(ix, _)| ix)
433 .collect();
434 self.selected_index = std::cmp::min(
435 self.selected_index,
436 self.matching_indices.len().saturating_sub(1),
437 );
438 Task::ready(())
439 }
440
441 fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
442 if secondary {
443 self.stateful_modal
444 .update(cx, |modal, cx| {
445 (self.on_confirm)(self.template_entry.clone(), modal, window, cx)
446 })
447 .log_err();
448 } else {
449 let current = &mut self.candidate_features[self.matching_indices[self.selected_index]];
450 current.toggle_state = match current.toggle_state {
451 ToggleState::Selected => {
452 self.template_entry
453 .features_selected
454 .remove(¤t.feature);
455 ToggleState::Unselected
456 }
457 _ => {
458 self.template_entry
459 .features_selected
460 .insert(current.feature.clone());
461 ToggleState::Selected
462 }
463 };
464 }
465 }
466
467 fn dismissed(&mut self, window: &mut Window, cx: &mut Context<Picker<Self>>) {
468 self.stateful_modal
469 .update(cx, |modal, cx| {
470 modal.dismiss(&menu::Cancel, window, cx);
471 })
472 .log_err();
473 }
474
475 fn render_match(
476 &self,
477 ix: usize,
478 selected: bool,
479 _window: &mut Window,
480 _cx: &mut Context<Picker<Self>>,
481 ) -> Option<Self::ListItem> {
482 let feature_entry = self.candidate_features[self.matching_indices[ix]].clone();
483
484 Some(
485 ListItem::new("li-what")
486 .inset(true)
487 .toggle_state(selected)
488 .start_slot(Switch::new(
489 feature_entry.feature.id.clone(),
490 feature_entry.toggle_state,
491 ))
492 .child(Label::new(feature_entry.feature.name))
493 .into_any_element(),
494 )
495 }
496
497 fn render_footer(
498 &self,
499 _window: &mut Window,
500 cx: &mut Context<Picker<Self>>,
501 ) -> Option<AnyElement> {
502 Some(
503 h_flex()
504 .w_full()
505 .p_1p5()
506 .gap_1()
507 .justify_start()
508 .border_t_1()
509 .border_color(cx.theme().colors().border_variant)
510 .child(
511 Button::new("run-action", "Select Feature")
512 .key_binding(
513 KeyBinding::for_action(&menu::Confirm, cx)
514 .map(|kb| kb.size(rems_from_px(12.))),
515 )
516 .on_click(|_, window, cx| {
517 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
518 }),
519 )
520 .child(
521 Button::new("run-action-secondary", "Confirm Selections")
522 .key_binding(
523 KeyBinding::for_action(&menu::SecondaryConfirm, cx)
524 .map(|kb| kb.size(rems_from_px(12.))),
525 )
526 .on_click(|_, window, cx| {
527 window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
528 }),
529 )
530 .into_any_element(),
531 )
532 }
533}
534
535impl DevContainerModal {
536 fn new(workspace: WeakEntity<Workspace>, _window: &mut Window, cx: &mut App) -> Self {
537 DevContainerModal {
538 workspace,
539 picker: None,
540 features_picker: None,
541 state: DevContainerState::Initial,
542 focus_handle: cx.focus_handle(),
543 confirm_entry: NavigableEntry::focusable(cx),
544 back_entry: NavigableEntry::focusable(cx),
545 }
546 }
547
548 fn render_initial(&self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
549 let mut view = Navigable::new(
550 div()
551 .p_1()
552 .child(
553 div().track_focus(&self.focus_handle).child(
554 ModalHeader::new().child(
555 Headline::new("Create Dev Container").size(HeadlineSize::XSmall),
556 ),
557 ),
558 )
559 .child(ListSeparator)
560 .child(
561 div()
562 .track_focus(&self.confirm_entry.focus_handle)
563 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
564 this.accept_message(DevContainerMessage::SearchTemplates, window, cx);
565 }))
566 .child(
567 ListItem::new("li-search-containers")
568 .inset(true)
569 .spacing(ui::ListItemSpacing::Sparse)
570 .start_slot(
571 Icon::new(IconName::MagnifyingGlass).color(Color::Muted),
572 )
573 .toggle_state(
574 self.confirm_entry.focus_handle.contains_focused(window, cx),
575 )
576 .on_click(cx.listener(|this, _, window, cx| {
577 this.accept_message(
578 DevContainerMessage::SearchTemplates,
579 window,
580 cx,
581 );
582 cx.notify();
583 }))
584 .child(Label::new("Search for Dev Container Templates")),
585 ),
586 )
587 .into_any_element(),
588 );
589 view = view.entry(self.confirm_entry.clone());
590 view.render(window, cx).into_any_element()
591 }
592
593 fn render_error(
594 &self,
595 error_title: String,
596 error: impl Display,
597 _window: &mut Window,
598 _cx: &mut Context<Self>,
599 ) -> AnyElement {
600 v_flex()
601 .p_1()
602 .child(div().track_focus(&self.focus_handle).child(
603 ModalHeader::new().child(Headline::new(error_title).size(HeadlineSize::XSmall)),
604 ))
605 .child(ListSeparator)
606 .child(
607 v_flex()
608 .child(Label::new(format!("{}", error)))
609 .whitespace_normal(),
610 )
611 .into_any_element()
612 }
613
614 fn render_retrieved_templates(
615 &self,
616 window: &mut Window,
617 cx: &mut Context<Self>,
618 ) -> AnyElement {
619 if let Some(picker) = &self.picker {
620 let picker_element = div()
621 .track_focus(&self.focus_handle(cx))
622 .child(picker.clone().into_any_element())
623 .into_any_element();
624 picker.focus_handle(cx).focus(window, cx);
625 picker_element
626 } else {
627 div().into_any_element()
628 }
629 }
630
631 fn render_user_options_specifying(
632 &self,
633 template_entry: TemplateEntry,
634 window: &mut Window,
635 cx: &mut Context<Self>,
636 ) -> AnyElement {
637 let Some(next_option_entries) = &template_entry.current_option else {
638 return div().into_any_element();
639 };
640 let mut view = Navigable::new(
641 div()
642 .child(
643 div()
644 .id("title")
645 .tooltip(Tooltip::text(next_option_entries.description.clone()))
646 .track_focus(&self.focus_handle)
647 .child(
648 ModalHeader::new()
649 .child(
650 Headline::new("Template Option: ").size(HeadlineSize::XSmall),
651 )
652 .child(
653 Headline::new(&next_option_entries.option_name)
654 .size(HeadlineSize::XSmall),
655 ),
656 ),
657 )
658 .child(ListSeparator)
659 .children(
660 next_option_entries
661 .navigable_options
662 .iter()
663 .map(|(option, entry)| {
664 div()
665 .id(format!("li-parent-{}", option))
666 .track_focus(&entry.focus_handle)
667 .on_action({
668 let mut template = template_entry.clone();
669 template.options_selected.insert(
670 next_option_entries.option_name.clone(),
671 option.clone(),
672 );
673 cx.listener(move |this, _: &menu::Confirm, window, cx| {
674 this.accept_message(
675 DevContainerMessage::TemplateOptionsSpecified(
676 template.clone(),
677 ),
678 window,
679 cx,
680 );
681 })
682 })
683 .child(
684 ListItem::new(format!("li-option-{}", option))
685 .inset(true)
686 .spacing(ui::ListItemSpacing::Sparse)
687 .toggle_state(
688 entry.focus_handle.contains_focused(window, cx),
689 )
690 .on_click({
691 let mut template = template_entry.clone();
692 template.options_selected.insert(
693 next_option_entries.option_name.clone(),
694 option.clone(),
695 );
696 cx.listener(move |this, _, window, cx| {
697 this.accept_message(
698 DevContainerMessage::TemplateOptionsSpecified(
699 template.clone(),
700 ),
701 window,
702 cx,
703 );
704 cx.notify();
705 })
706 })
707 .child(Label::new(option)),
708 )
709 }),
710 )
711 .child(ListSeparator)
712 .child(
713 div()
714 .track_focus(&self.back_entry.focus_handle)
715 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
716 this.accept_message(DevContainerMessage::GoBack, window, cx);
717 }))
718 .child(
719 ListItem::new("li-goback")
720 .inset(true)
721 .spacing(ui::ListItemSpacing::Sparse)
722 .start_slot(Icon::new(IconName::Return).color(Color::Muted))
723 .toggle_state(
724 self.back_entry.focus_handle.contains_focused(window, cx),
725 )
726 .on_click(cx.listener(|this, _, window, cx| {
727 this.accept_message(DevContainerMessage::GoBack, window, cx);
728 cx.notify();
729 }))
730 .child(Label::new("Go Back")),
731 ),
732 )
733 .into_any_element(),
734 );
735 for (_, entry) in &next_option_entries.navigable_options {
736 view = view.entry(entry.clone());
737 }
738 view = view.entry(self.back_entry.clone());
739 view.render(window, cx).into_any_element()
740 }
741
742 fn render_features_query_returned(
743 &self,
744 window: &mut Window,
745 cx: &mut Context<Self>,
746 ) -> AnyElement {
747 if let Some(picker) = &self.features_picker {
748 let picker_element = div()
749 .track_focus(&self.focus_handle(cx))
750 .child(picker.clone().into_any_element())
751 .into_any_element();
752 picker.focus_handle(cx).focus(window, cx);
753 picker_element
754 } else {
755 div().into_any_element()
756 }
757 }
758
759 fn render_confirming_write_dev_container(
760 &self,
761 template_entry: TemplateEntry,
762 window: &mut Window,
763 cx: &mut Context<Self>,
764 ) -> AnyElement {
765 Navigable::new(
766 div()
767 .child(
768 div().track_focus(&self.focus_handle).child(
769 ModalHeader::new()
770 .icon(Icon::new(IconName::Warning).color(Color::Warning))
771 .child(
772 Headline::new("Overwrite Existing Configuration?")
773 .size(HeadlineSize::XSmall),
774 ),
775 ),
776 )
777 .child(
778 div()
779 .track_focus(&self.confirm_entry.focus_handle)
780 .on_action({
781 let template = template_entry.clone();
782 cx.listener(move |this, _: &menu::Confirm, window, cx| {
783 this.accept_message(
784 DevContainerMessage::ConfirmWriteDevContainer(template.clone()),
785 window,
786 cx,
787 );
788 })
789 })
790 .child(
791 ListItem::new("li-search-containers")
792 .inset(true)
793 .spacing(ui::ListItemSpacing::Sparse)
794 .start_slot(Icon::new(IconName::Check).color(Color::Muted))
795 .toggle_state(
796 self.confirm_entry.focus_handle.contains_focused(window, cx),
797 )
798 .on_click(cx.listener(move |this, _, window, cx| {
799 this.accept_message(
800 DevContainerMessage::ConfirmWriteDevContainer(
801 template_entry.clone(),
802 ),
803 window,
804 cx,
805 );
806 cx.notify();
807 }))
808 .child(Label::new("Overwrite")),
809 ),
810 )
811 .child(
812 div()
813 .track_focus(&self.back_entry.focus_handle)
814 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
815 this.dismiss(&menu::Cancel, window, cx);
816 }))
817 .child(
818 ListItem::new("li-goback")
819 .inset(true)
820 .spacing(ui::ListItemSpacing::Sparse)
821 .start_slot(Icon::new(IconName::XCircle).color(Color::Muted))
822 .toggle_state(
823 self.back_entry.focus_handle.contains_focused(window, cx),
824 )
825 .on_click(cx.listener(|this, _, window, cx| {
826 this.dismiss(&menu::Cancel, window, cx);
827 cx.notify();
828 }))
829 .child(Label::new("Cancel")),
830 ),
831 )
832 .into_any_element(),
833 )
834 .entry(self.confirm_entry.clone())
835 .entry(self.back_entry.clone())
836 .render(window, cx)
837 .into_any_element()
838 }
839
840 fn render_querying_templates(&self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
841 Navigable::new(
842 div()
843 .child(
844 div().track_focus(&self.focus_handle).child(
845 ModalHeader::new().child(
846 Headline::new("Create Dev Container").size(HeadlineSize::XSmall),
847 ),
848 ),
849 )
850 .child(ListSeparator)
851 .child(
852 div().child(
853 ListItem::new("li-querying")
854 .inset(true)
855 .spacing(ui::ListItemSpacing::Sparse)
856 .start_slot(
857 Icon::new(IconName::ArrowCircle)
858 .color(Color::Muted)
859 .with_rotate_animation(2),
860 )
861 .child(Label::new("Querying template registry...")),
862 ),
863 )
864 .child(ListSeparator)
865 .child(
866 div()
867 .track_focus(&self.back_entry.focus_handle)
868 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
869 this.accept_message(DevContainerMessage::GoBack, window, cx);
870 }))
871 .child(
872 ListItem::new("li-goback")
873 .inset(true)
874 .spacing(ui::ListItemSpacing::Sparse)
875 .start_slot(Icon::new(IconName::Pencil).color(Color::Muted))
876 .toggle_state(
877 self.back_entry.focus_handle.contains_focused(window, cx),
878 )
879 .on_click(cx.listener(|this, _, window, cx| {
880 this.accept_message(DevContainerMessage::GoBack, window, cx);
881 cx.notify();
882 }))
883 .child(Label::new("Go Back")),
884 ),
885 )
886 .into_any_element(),
887 )
888 .entry(self.back_entry.clone())
889 .render(window, cx)
890 .into_any_element()
891 }
892 fn render_querying_features(&self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
893 Navigable::new(
894 div()
895 .child(
896 div().track_focus(&self.focus_handle).child(
897 ModalHeader::new().child(
898 Headline::new("Create Dev Container").size(HeadlineSize::XSmall),
899 ),
900 ),
901 )
902 .child(ListSeparator)
903 .child(
904 div().child(
905 ListItem::new("li-querying")
906 .inset(true)
907 .spacing(ui::ListItemSpacing::Sparse)
908 .start_slot(
909 Icon::new(IconName::ArrowCircle)
910 .color(Color::Muted)
911 .with_rotate_animation(2),
912 )
913 .child(Label::new("Querying features...")),
914 ),
915 )
916 .child(ListSeparator)
917 .child(
918 div()
919 .track_focus(&self.back_entry.focus_handle)
920 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
921 this.accept_message(DevContainerMessage::GoBack, window, cx);
922 }))
923 .child(
924 ListItem::new("li-goback")
925 .inset(true)
926 .spacing(ui::ListItemSpacing::Sparse)
927 .start_slot(Icon::new(IconName::Pencil).color(Color::Muted))
928 .toggle_state(
929 self.back_entry.focus_handle.contains_focused(window, cx),
930 )
931 .on_click(cx.listener(|this, _, window, cx| {
932 this.accept_message(DevContainerMessage::GoBack, window, cx);
933 cx.notify();
934 }))
935 .child(Label::new("Go Back")),
936 ),
937 )
938 .into_any_element(),
939 )
940 .entry(self.back_entry.clone())
941 .render(window, cx)
942 .into_any_element()
943 }
944}
945
946impl StatefulModal for DevContainerModal {
947 type State = DevContainerState;
948 type Message = DevContainerMessage;
949
950 fn state(&self) -> Self::State {
951 self.state.clone()
952 }
953
954 fn render_for_state(
955 &self,
956 state: Self::State,
957 window: &mut Window,
958 cx: &mut Context<Self>,
959 ) -> AnyElement {
960 match state {
961 DevContainerState::Initial => self.render_initial(window, cx),
962 DevContainerState::QueryingTemplates => self.render_querying_templates(window, cx),
963 DevContainerState::TemplateQueryReturned(Ok(_)) => {
964 self.render_retrieved_templates(window, cx)
965 }
966 DevContainerState::UserOptionsSpecifying(template_entry) => {
967 self.render_user_options_specifying(template_entry, window, cx)
968 }
969 DevContainerState::QueryingFeatures(_) => self.render_querying_features(window, cx),
970 DevContainerState::FeaturesQueryReturned(_) => {
971 self.render_features_query_returned(window, cx)
972 }
973 DevContainerState::ConfirmingWriteDevContainer(template_entry) => {
974 self.render_confirming_write_dev_container(template_entry, window, cx)
975 }
976 DevContainerState::TemplateWriteFailed(dev_container_error) => self.render_error(
977 "Error Creating Dev Container Definition".to_string(),
978 dev_container_error,
979 window,
980 cx,
981 ),
982 DevContainerState::TemplateQueryReturned(Err(e)) => {
983 self.render_error("Error Retrieving Templates".to_string(), e, window, cx)
984 }
985 }
986 }
987
988 fn accept_message(
989 &mut self,
990 message: Self::Message,
991 window: &mut Window,
992 cx: &mut Context<Self>,
993 ) {
994 let new_state = match message {
995 DevContainerMessage::SearchTemplates => {
996 cx.spawn_in(window, async move |this, cx| {
997 let client = cx.update(|_, cx| cx.http_client()).unwrap();
998 match get_templates(client).await {
999 Ok(templates) => {
1000 let message =
1001 DevContainerMessage::TemplatesRetrieved(templates.templates);
1002 this.update_in(cx, |this, window, cx| {
1003 this.accept_message(message, window, cx);
1004 })
1005 .log_err();
1006 }
1007 Err(e) => {
1008 let message = DevContainerMessage::ErrorRetrievingTemplates(e);
1009 this.update_in(cx, |this, window, cx| {
1010 this.accept_message(message, window, cx);
1011 })
1012 .log_err();
1013 }
1014 }
1015 })
1016 .detach();
1017 Some(DevContainerState::QueryingTemplates)
1018 }
1019 DevContainerMessage::ErrorRetrievingTemplates(message) => {
1020 Some(DevContainerState::TemplateQueryReturned(Err(message)))
1021 }
1022 DevContainerMessage::GoBack => match &self.state {
1023 DevContainerState::Initial => Some(DevContainerState::Initial),
1024 DevContainerState::QueryingTemplates => Some(DevContainerState::Initial),
1025 DevContainerState::UserOptionsSpecifying(template_entry) => {
1026 if template_entry.current_option_index <= 1 {
1027 self.accept_message(DevContainerMessage::SearchTemplates, window, cx);
1028 } else {
1029 let mut template_entry = template_entry.clone();
1030 template_entry.current_option_index =
1031 template_entry.current_option_index.saturating_sub(2);
1032 self.accept_message(
1033 DevContainerMessage::TemplateOptionsSpecified(template_entry),
1034 window,
1035 cx,
1036 );
1037 }
1038 None
1039 }
1040 _ => Some(DevContainerState::Initial),
1041 },
1042 DevContainerMessage::TemplatesRetrieved(items) => {
1043 let items = items
1044 .into_iter()
1045 .map(|item| TemplateEntry {
1046 template: item,
1047 options_selected: HashMap::new(),
1048 current_option_index: 0,
1049 current_option: None,
1050 features_selected: HashSet::new(),
1051 })
1052 .collect::<Vec<TemplateEntry>>();
1053 if self.state == DevContainerState::QueryingTemplates {
1054 let delegate = TemplatePickerDelegate::new(
1055 "Select a template".to_string(),
1056 cx.weak_entity(),
1057 items.clone(),
1058 Box::new(|entry, this, window, cx| {
1059 this.accept_message(
1060 DevContainerMessage::TemplateSelected(entry),
1061 window,
1062 cx,
1063 );
1064 }),
1065 );
1066
1067 let picker =
1068 cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false));
1069 self.picker = Some(picker);
1070 Some(DevContainerState::TemplateQueryReturned(Ok(items)))
1071 } else {
1072 None
1073 }
1074 }
1075 DevContainerMessage::TemplateSelected(mut template_entry) => {
1076 let Some(options) = template_entry.template.clone().options else {
1077 return self.accept_message(
1078 DevContainerMessage::TemplateOptionsCompleted(template_entry),
1079 window,
1080 cx,
1081 );
1082 };
1083
1084 let options = options
1085 .iter()
1086 .collect::<Vec<(&String, &TemplateOptions)>>()
1087 .clone();
1088
1089 let Some((first_option_name, first_option)) =
1090 options.get(template_entry.current_option_index)
1091 else {
1092 return self.accept_message(
1093 DevContainerMessage::TemplateOptionsCompleted(template_entry),
1094 window,
1095 cx,
1096 );
1097 };
1098
1099 let next_option_entries = first_option
1100 .possible_values()
1101 .into_iter()
1102 .map(|option| (option, NavigableEntry::focusable(cx)))
1103 .collect();
1104
1105 template_entry.current_option_index += 1;
1106 template_entry.current_option = Some(TemplateOptionSelection {
1107 option_name: (*first_option_name).clone(),
1108 description: first_option
1109 .description
1110 .clone()
1111 .unwrap_or_else(|| "".to_string()),
1112 navigable_options: next_option_entries,
1113 });
1114
1115 Some(DevContainerState::UserOptionsSpecifying(template_entry))
1116 }
1117 DevContainerMessage::TemplateOptionsSpecified(mut template_entry) => {
1118 let Some(options) = template_entry.template.clone().options else {
1119 return self.accept_message(
1120 DevContainerMessage::TemplateOptionsCompleted(template_entry),
1121 window,
1122 cx,
1123 );
1124 };
1125
1126 let options = options
1127 .iter()
1128 .collect::<Vec<(&String, &TemplateOptions)>>()
1129 .clone();
1130
1131 let Some((next_option_name, next_option)) =
1132 options.get(template_entry.current_option_index)
1133 else {
1134 return self.accept_message(
1135 DevContainerMessage::TemplateOptionsCompleted(template_entry),
1136 window,
1137 cx,
1138 );
1139 };
1140
1141 let next_option_entries = next_option
1142 .possible_values()
1143 .into_iter()
1144 .map(|option| (option, NavigableEntry::focusable(cx)))
1145 .collect();
1146
1147 template_entry.current_option_index += 1;
1148 template_entry.current_option = Some(TemplateOptionSelection {
1149 option_name: (*next_option_name).clone(),
1150 description: next_option
1151 .description
1152 .clone()
1153 .unwrap_or_else(|| "".to_string()),
1154 navigable_options: next_option_entries,
1155 });
1156
1157 Some(DevContainerState::UserOptionsSpecifying(template_entry))
1158 }
1159 DevContainerMessage::TemplateOptionsCompleted(template_entry) => {
1160 cx.spawn_in(window, async move |this, cx| {
1161 let client = cx.update(|_, cx| cx.http_client()).unwrap();
1162 let Some(features) = get_features(client).await.log_err() else {
1163 return;
1164 };
1165 let message = DevContainerMessage::FeaturesRetrieved(features.features);
1166 this.update_in(cx, |this, window, cx| {
1167 this.accept_message(message, window, cx);
1168 })
1169 .log_err();
1170 })
1171 .detach();
1172 Some(DevContainerState::QueryingFeatures(template_entry))
1173 }
1174 DevContainerMessage::FeaturesRetrieved(features) => {
1175 if let DevContainerState::QueryingFeatures(template_entry) = self.state.clone() {
1176 let features = features
1177 .iter()
1178 .map(|feature| FeatureEntry {
1179 feature: feature.clone(),
1180 toggle_state: ToggleState::Unselected,
1181 })
1182 .collect::<Vec<FeatureEntry>>();
1183 let delegate = FeaturePickerDelegate::new(
1184 "Select features to add".to_string(),
1185 cx.weak_entity(),
1186 features,
1187 template_entry.clone(),
1188 Box::new(|entry, this, window, cx| {
1189 this.accept_message(
1190 DevContainerMessage::FeaturesSelected(entry),
1191 window,
1192 cx,
1193 );
1194 }),
1195 );
1196
1197 let picker =
1198 cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false));
1199 self.features_picker = Some(picker);
1200 Some(DevContainerState::FeaturesQueryReturned(template_entry))
1201 } else {
1202 None
1203 }
1204 }
1205 DevContainerMessage::FeaturesSelected(template_entry) => {
1206 if let Some(workspace) = self.workspace.upgrade() {
1207 dispatch_apply_templates(template_entry, workspace, window, true, cx);
1208 }
1209
1210 None
1211 }
1212 DevContainerMessage::NeedConfirmWriteDevContainer(template_entry) => Some(
1213 DevContainerState::ConfirmingWriteDevContainer(template_entry),
1214 ),
1215 DevContainerMessage::ConfirmWriteDevContainer(template_entry) => {
1216 if let Some(workspace) = self.workspace.upgrade() {
1217 dispatch_apply_templates(template_entry, workspace, window, false, cx);
1218 }
1219 None
1220 }
1221 DevContainerMessage::FailedToWriteTemplate(error) => {
1222 Some(DevContainerState::TemplateWriteFailed(error))
1223 }
1224 };
1225 if let Some(state) = new_state {
1226 self.state = state;
1227 self.focus_handle.focus(window, cx);
1228 }
1229 cx.notify();
1230 }
1231}
1232impl EventEmitter<DismissEvent> for DevContainerModal {}
1233impl Focusable for DevContainerModal {
1234 fn focus_handle(&self, _: &App) -> FocusHandle {
1235 self.focus_handle.clone()
1236 }
1237}
1238impl ModalView for DevContainerModal {}
1239
1240impl Render for DevContainerModal {
1241 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1242 self.render_inner(window, cx)
1243 }
1244}
1245
1246trait StatefulModal: ModalView + EventEmitter<DismissEvent> + Render {
1247 type State;
1248 type Message;
1249
1250 fn state(&self) -> Self::State;
1251
1252 fn render_for_state(
1253 &self,
1254 state: Self::State,
1255 window: &mut Window,
1256 cx: &mut Context<Self>,
1257 ) -> AnyElement;
1258
1259 fn accept_message(
1260 &mut self,
1261 message: Self::Message,
1262 window: &mut Window,
1263 cx: &mut Context<Self>,
1264 );
1265
1266 fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
1267 cx.emit(DismissEvent);
1268 }
1269
1270 fn render_inner(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1271 let element = self.render_for_state(self.state(), window, cx);
1272 div()
1273 .elevation_3(cx)
1274 .w(rems(34.))
1275 .key_context("ContainerModal")
1276 .on_action(cx.listener(Self::dismiss))
1277 .child(element)
1278 }
1279}
1280
1281#[derive(Debug, Deserialize)]
1282#[serde(rename_all = "camelCase")]
1283struct GithubTokenResponse {
1284 token: String,
1285}
1286
1287fn ghcr_url() -> &'static str {
1288 "https://ghcr.io"
1289}
1290
1291fn ghcr_domain() -> &'static str {
1292 "ghcr.io"
1293}
1294
1295fn devcontainer_templates_repository() -> &'static str {
1296 "devcontainers/templates"
1297}
1298
1299fn devcontainer_features_repository() -> &'static str {
1300 "devcontainers/features"
1301}
1302
1303#[derive(Debug, Deserialize)]
1304#[serde(rename_all = "camelCase")]
1305struct ManifestLayer {
1306 digest: String,
1307}
1308#[derive(Debug, Deserialize, Clone, PartialEq, Eq)]
1309#[serde(rename_all = "camelCase")]
1310struct TemplateOptions {
1311 #[serde(rename = "type")]
1312 option_type: String,
1313 description: Option<String>,
1314 proposals: Option<Vec<String>>,
1315 #[serde(rename = "enum")]
1316 enum_values: Option<Vec<String>>,
1317 // Different repositories surface "default: 'true'" or "default: true",
1318 // so we need to be flexible in deserializing
1319 #[serde(deserialize_with = "deserialize_string_or_bool")]
1320 default: String,
1321}
1322
1323fn deserialize_string_or_bool<'de, D>(deserializer: D) -> Result<String, D::Error>
1324where
1325 D: serde::Deserializer<'de>,
1326{
1327 use serde::Deserialize;
1328
1329 #[derive(Deserialize)]
1330 #[serde(untagged)]
1331 enum StringOrBool {
1332 String(String),
1333 Bool(bool),
1334 }
1335
1336 match StringOrBool::deserialize(deserializer)? {
1337 StringOrBool::String(s) => Ok(s),
1338 StringOrBool::Bool(b) => Ok(b.to_string()),
1339 }
1340}
1341
1342impl TemplateOptions {
1343 fn possible_values(&self) -> Vec<String> {
1344 match self.option_type.as_str() {
1345 "string" => self
1346 .enum_values
1347 .clone()
1348 .or(self.proposals.clone().or(Some(vec![self.default.clone()])))
1349 .unwrap_or_default(),
1350 // If not string, must be boolean
1351 _ => {
1352 if self.default == "true" {
1353 vec!["true".to_string(), "false".to_string()]
1354 } else {
1355 vec!["false".to_string(), "true".to_string()]
1356 }
1357 }
1358 }
1359 }
1360}
1361
1362#[derive(Debug, Deserialize)]
1363#[serde(rename_all = "camelCase")]
1364struct DockerManifestsResponse {
1365 layers: Vec<ManifestLayer>,
1366}
1367
1368#[derive(Debug, Deserialize, Clone, PartialEq, Eq, Hash)]
1369#[serde(rename_all = "camelCase")]
1370struct DevContainerFeature {
1371 id: String,
1372 version: String,
1373 name: String,
1374 source_repository: Option<String>,
1375}
1376
1377impl DevContainerFeature {
1378 fn major_version(&self) -> String {
1379 let Some(mv) = self.version.get(..1) else {
1380 return "".to_string();
1381 };
1382 mv.to_string()
1383 }
1384}
1385
1386#[derive(Debug, Deserialize, Clone, PartialEq, Eq)]
1387#[serde(rename_all = "camelCase")]
1388struct DevContainerTemplate {
1389 id: String,
1390 name: String,
1391 options: Option<HashMap<String, TemplateOptions>>,
1392 source_repository: Option<String>,
1393}
1394
1395#[derive(Debug, Deserialize)]
1396#[serde(rename_all = "camelCase")]
1397struct DevContainerFeaturesResponse {
1398 features: Vec<DevContainerFeature>,
1399}
1400
1401#[derive(Debug, Deserialize)]
1402#[serde(rename_all = "camelCase")]
1403struct DevContainerTemplatesResponse {
1404 templates: Vec<DevContainerTemplate>,
1405}
1406
1407fn dispatch_apply_templates(
1408 template_entry: TemplateEntry,
1409 workspace: Entity<Workspace>,
1410 window: &mut Window,
1411 check_for_existing: bool,
1412 cx: &mut Context<DevContainerModal>,
1413) {
1414 cx.spawn_in(window, async move |this, cx| {
1415 if let Some(tree_id) = workspace.update(cx, |workspace, cx| {
1416 let project = workspace.project().clone();
1417 let worktree = project.read(cx).visible_worktrees(cx).find_map(|tree| {
1418 tree.read(cx)
1419 .root_entry()?
1420 .is_dir()
1421 .then_some(tree.read(cx))
1422 });
1423 worktree.map(|w| w.id())
1424 }) {
1425 let node_runtime = workspace.read_with(cx, |workspace, _| {
1426 workspace.app_state().node_runtime.clone()
1427 });
1428
1429 if check_for_existing
1430 && read_devcontainer_configuration_for_project(cx, &node_runtime)
1431 .await
1432 .is_ok()
1433 {
1434 this.update_in(cx, |this, window, cx| {
1435 this.accept_message(
1436 DevContainerMessage::NeedConfirmWriteDevContainer(template_entry),
1437 window,
1438 cx,
1439 );
1440 })
1441 .log_err();
1442 return;
1443 }
1444
1445 let files = match apply_dev_container_template(
1446 &template_entry.template,
1447 &template_entry.options_selected,
1448 &template_entry.features_selected,
1449 cx,
1450 &node_runtime,
1451 )
1452 .await
1453 {
1454 Ok(files) => files,
1455 Err(e) => {
1456 this.update_in(cx, |this, window, cx| {
1457 this.accept_message(
1458 DevContainerMessage::FailedToWriteTemplate(e),
1459 window,
1460 cx,
1461 );
1462 })
1463 .log_err();
1464 return;
1465 }
1466 };
1467
1468 if files
1469 .files
1470 .contains(&"./.devcontainer/devcontainer.json".to_string())
1471 {
1472 let Some(workspace_task) = workspace
1473 .update_in(cx, |workspace, window, cx| {
1474 let path = RelPath::unix(".devcontainer/devcontainer.json").unwrap();
1475 workspace.open_path((tree_id, path), None, true, window, cx)
1476 })
1477 .log_err()
1478 else {
1479 return;
1480 };
1481
1482 workspace_task.await.log_err();
1483 }
1484 this.update_in(cx, |this, window, cx| {
1485 this.dismiss(&menu::Cancel, window, cx);
1486 })
1487 .unwrap();
1488 } else {
1489 return;
1490 }
1491 })
1492 .detach();
1493}
1494
1495async fn get_templates(
1496 client: Arc<dyn HttpClient>,
1497) -> Result<DevContainerTemplatesResponse, String> {
1498 let token = get_ghcr_token(&client).await?;
1499 let manifest = get_latest_manifest(&token.token, &client).await?;
1500
1501 let mut template_response =
1502 get_devcontainer_templates(&token.token, &manifest.layers[0].digest, &client).await?;
1503
1504 for template in &mut template_response.templates {
1505 template.source_repository = Some(format!(
1506 "{}/{}",
1507 ghcr_domain(),
1508 devcontainer_templates_repository()
1509 ));
1510 }
1511 Ok(template_response)
1512}
1513
1514async fn get_features(client: Arc<dyn HttpClient>) -> Result<DevContainerFeaturesResponse, String> {
1515 let token = get_ghcr_token(&client).await?;
1516 let manifest = get_latest_feature_manifest(&token.token, &client).await?;
1517
1518 let mut features_response =
1519 get_devcontainer_features(&token.token, &manifest.layers[0].digest, &client).await?;
1520
1521 for feature in &mut features_response.features {
1522 feature.source_repository = Some(format!(
1523 "{}/{}",
1524 ghcr_domain(),
1525 devcontainer_features_repository()
1526 ));
1527 }
1528 Ok(features_response)
1529}
1530
1531async fn get_ghcr_token(client: &Arc<dyn HttpClient>) -> Result<GithubTokenResponse, String> {
1532 let url = format!(
1533 "{}/token?service=ghcr.io&scope=repository:{}:pull",
1534 ghcr_url(),
1535 devcontainer_templates_repository()
1536 );
1537 get_deserialized_response("", &url, client).await
1538}
1539
1540async fn get_latest_feature_manifest(
1541 token: &str,
1542 client: &Arc<dyn HttpClient>,
1543) -> Result<DockerManifestsResponse, String> {
1544 let url = format!(
1545 "{}/v2/{}/manifests/latest",
1546 ghcr_url(),
1547 devcontainer_features_repository()
1548 );
1549 get_deserialized_response(token, &url, client).await
1550}
1551
1552async fn get_latest_manifest(
1553 token: &str,
1554 client: &Arc<dyn HttpClient>,
1555) -> Result<DockerManifestsResponse, String> {
1556 let url = format!(
1557 "{}/v2/{}/manifests/latest",
1558 ghcr_url(),
1559 devcontainer_templates_repository()
1560 );
1561 get_deserialized_response(token, &url, client).await
1562}
1563
1564async fn get_devcontainer_features(
1565 token: &str,
1566 blob_digest: &str,
1567 client: &Arc<dyn HttpClient>,
1568) -> Result<DevContainerFeaturesResponse, String> {
1569 let url = format!(
1570 "{}/v2/{}/blobs/{}",
1571 ghcr_url(),
1572 devcontainer_features_repository(),
1573 blob_digest
1574 );
1575 get_deserialized_response(token, &url, client).await
1576}
1577
1578async fn get_devcontainer_templates(
1579 token: &str,
1580 blob_digest: &str,
1581 client: &Arc<dyn HttpClient>,
1582) -> Result<DevContainerTemplatesResponse, String> {
1583 let url = format!(
1584 "{}/v2/{}/blobs/{}",
1585 ghcr_url(),
1586 devcontainer_templates_repository(),
1587 blob_digest
1588 );
1589 get_deserialized_response(token, &url, client).await
1590}
1591
1592async fn get_deserialized_response<T>(
1593 token: &str,
1594 url: &str,
1595 client: &Arc<dyn HttpClient>,
1596) -> Result<T, String>
1597where
1598 T: for<'de> Deserialize<'de>,
1599{
1600 let request = Request::get(url)
1601 .header("Authorization", format!("Bearer {}", token))
1602 .header("Accept", "application/vnd.oci.image.manifest.v1+json")
1603 .body(AsyncBody::default())
1604 .unwrap();
1605 let response = match client.send(request).await {
1606 Ok(response) => response,
1607 Err(e) => {
1608 return Err(format!("Failed to send request: {}", e));
1609 }
1610 };
1611
1612 let mut output = String::new();
1613
1614 if let Err(e) = response.into_body().read_to_string(&mut output).await {
1615 return Err(format!("Failed to read response body: {}", e));
1616 };
1617
1618 match serde_json::from_str(&output) {
1619 Ok(response) => Ok(response),
1620 Err(e) => Err(format!("Failed to deserialize response: {}", e)),
1621 }
1622}
1623
1624#[cfg(test)]
1625mod tests {
1626 use gpui::TestAppContext;
1627 use http_client::{FakeHttpClient, anyhow};
1628
1629 use crate::{
1630 GithubTokenResponse, devcontainer_templates_repository, get_deserialized_response,
1631 get_devcontainer_templates, get_ghcr_token, get_latest_manifest,
1632 };
1633
1634 #[gpui::test]
1635 async fn test_get_deserialized_response(_cx: &mut TestAppContext) {
1636 let client = FakeHttpClient::create(|_request| async move {
1637 Ok(http_client::Response::builder()
1638 .status(200)
1639 .body("{ \"token\": \"thisisatoken\" }".into())
1640 .unwrap())
1641 });
1642
1643 let response =
1644 get_deserialized_response::<GithubTokenResponse>("", "https://ghcr.io/token", &client)
1645 .await;
1646 assert!(response.is_ok());
1647 assert_eq!(response.unwrap().token, "thisisatoken".to_string())
1648 }
1649
1650 #[gpui::test]
1651 async fn test_get_ghcr_token() {
1652 let client = FakeHttpClient::create(|request| async move {
1653 let host = request.uri().host();
1654 if host.is_none() || host.unwrap() != "ghcr.io" {
1655 return Err(anyhow!("Unexpected host: {}", host.unwrap_or_default()));
1656 }
1657 let path = request.uri().path();
1658 if path != "/token" {
1659 return Err(anyhow!("Unexpected path: {}", path));
1660 }
1661 let query = request.uri().query();
1662 if query.is_none()
1663 || query.unwrap()
1664 != format!(
1665 "service=ghcr.io&scope=repository:{}:pull",
1666 devcontainer_templates_repository()
1667 )
1668 {
1669 return Err(anyhow!("Unexpected query: {}", query.unwrap_or_default()));
1670 }
1671 Ok(http_client::Response::builder()
1672 .status(200)
1673 .body("{ \"token\": \"thisisatoken\" }".into())
1674 .unwrap())
1675 });
1676
1677 let response = get_ghcr_token(&client).await;
1678 assert!(response.is_ok());
1679 assert_eq!(response.unwrap().token, "thisisatoken".to_string());
1680 }
1681
1682 #[gpui::test]
1683 async fn test_get_latest_manifests() {
1684 let client = FakeHttpClient::create(|request| async move {
1685 let host = request.uri().host();
1686 if host.is_none() || host.unwrap() != "ghcr.io" {
1687 return Err(anyhow!("Unexpected host: {}", host.unwrap_or_default()));
1688 }
1689 let path = request.uri().path();
1690 if path
1691 != format!(
1692 "/v2/{}/manifests/latest",
1693 devcontainer_templates_repository()
1694 )
1695 {
1696 return Err(anyhow!("Unexpected path: {}", path));
1697 }
1698 Ok(http_client::Response::builder()
1699 .status(200)
1700 .body("{
1701 \"schemaVersion\": 2,
1702 \"mediaType\": \"application/vnd.oci.image.manifest.v1+json\",
1703 \"config\": {
1704 \"mediaType\": \"application/vnd.devcontainers\",
1705 \"digest\": \"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a\",
1706 \"size\": 2
1707 },
1708 \"layers\": [
1709 {
1710 \"mediaType\": \"application/vnd.devcontainers.collection.layer.v1+json\",
1711 \"digest\": \"sha256:035e9c9fd9bd61f6d3965fa4bf11f3ddfd2490a8cf324f152c13cc3724d67d09\",
1712 \"size\": 65235,
1713 \"annotations\": {
1714 \"org.opencontainers.image.title\": \"devcontainer-collection.json\"
1715 }
1716 }
1717 ],
1718 \"annotations\": {
1719 \"com.github.package.type\": \"devcontainer_collection\"
1720 }
1721 }".into())
1722 .unwrap())
1723 });
1724
1725 let response = get_latest_manifest("", &client).await;
1726 assert!(response.is_ok());
1727 let response = response.unwrap();
1728
1729 assert_eq!(response.layers.len(), 1);
1730 assert_eq!(
1731 response.layers[0].digest,
1732 "sha256:035e9c9fd9bd61f6d3965fa4bf11f3ddfd2490a8cf324f152c13cc3724d67d09"
1733 );
1734 }
1735
1736 #[gpui::test]
1737 async fn test_get_devcontainer_templates() {
1738 let client = FakeHttpClient::create(|request| async move {
1739 let host = request.uri().host();
1740 if host.is_none() || host.unwrap() != "ghcr.io" {
1741 return Err(anyhow!("Unexpected host: {}", host.unwrap_or_default()));
1742 }
1743 let path = request.uri().path();
1744 if path
1745 != format!(
1746 "/v2/{}/blobs/sha256:035e9c9fd9bd61f6d3965fa4bf11f3ddfd2490a8cf324f152c13cc3724d67d09",
1747 devcontainer_templates_repository()
1748 )
1749 {
1750 return Err(anyhow!("Unexpected path: {}", path));
1751 }
1752 Ok(http_client::Response::builder()
1753 .status(200)
1754 .body("{
1755 \"sourceInformation\": {
1756 \"source\": \"devcontainer-cli\"
1757 },
1758 \"templates\": [
1759 {
1760 \"id\": \"alpine\",
1761 \"version\": \"3.4.0\",
1762 \"name\": \"Alpine\",
1763 \"description\": \"Simple Alpine container with Git installed.\",
1764 \"documentationURL\": \"https://github.com/devcontainers/templates/tree/main/src/alpine\",
1765 \"publisher\": \"Dev Container Spec Maintainers\",
1766 \"licenseURL\": \"https://github.com/devcontainers/templates/blob/main/LICENSE\",
1767 \"options\": {
1768 \"imageVariant\": {
1769 \"type\": \"string\",
1770 \"description\": \"Alpine version:\",
1771 \"proposals\": [
1772 \"3.21\",
1773 \"3.20\",
1774 \"3.19\",
1775 \"3.18\"
1776 ],
1777 \"default\": \"3.20\"
1778 }
1779 },
1780 \"platforms\": [
1781 \"Any\"
1782 ],
1783 \"optionalPaths\": [
1784 \".github/dependabot.yml\"
1785 ],
1786 \"type\": \"image\",
1787 \"files\": [
1788 \"NOTES.md\",
1789 \"README.md\",
1790 \"devcontainer-template.json\",
1791 \".devcontainer/devcontainer.json\",
1792 \".github/dependabot.yml\"
1793 ],
1794 \"fileCount\": 5,
1795 \"featureIds\": []
1796 }
1797 ]
1798 }".into())
1799 .unwrap())
1800 });
1801 let response = get_devcontainer_templates(
1802 "",
1803 "sha256:035e9c9fd9bd61f6d3965fa4bf11f3ddfd2490a8cf324f152c13cc3724d67d09",
1804 &client,
1805 )
1806 .await;
1807 assert!(response.is_ok());
1808 let response = response.unwrap();
1809 assert_eq!(response.templates.len(), 1);
1810 assert_eq!(response.templates[0].name, "Alpine");
1811 }
1812}