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