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