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