Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T03:31:57.943Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

lib.rs

1773 lines · 63.9 KB · rust
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(&current.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
Served at tenant.openagents/omega Member data and write actions are omitted.