lib.rs

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