lib.rs

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