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