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