lib.rs

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