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