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