lib.rs

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