lib.rs

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