lib.rs

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