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