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