use std::path::Path; use gpui::AppContext; use gpui::Entity; use gpui::Task; use http_client::anyhow; use picker::Picker; use picker::PickerDelegate; use settings::RegisterSetting; use settings::Settings; use std::collections::HashMap; use std::collections::HashSet; use std::fmt::Debug; use std::fmt::Display; use std::sync::Arc; use ui::ActiveTheme; use ui::Button; use ui::Clickable; use ui::FluentBuilder; use ui::KeyBinding; use ui::StatefulInteractiveElement; use ui::Switch; use ui::ToggleState; use ui::Tooltip; use ui::h_flex; use ui::rems_from_px; use ui::v_flex; use gpui::{Action, DismissEvent, EventEmitter, FocusHandle, Focusable, RenderOnce, WeakEntity}; use serde::Deserialize; use ui::{ AnyElement, App, Color, CommonAnimationExt, Context, Headline, HeadlineSize, Icon, IconName, InteractiveElement, IntoElement, Label, ListItem, ListSeparator, ModalHeader, Navigable, NavigableEntry, ParentElement, Render, Styled, StyledExt, Toggleable, Window, div, rems, }; use util::ResultExt; use util::rel_path::RelPath; use workspace::{ModalView, Workspace, with_active_or_new_workspace}; use futures::AsyncReadExt; use http::Request; use http_client::{AsyncBody, HttpClient}; mod devcontainer_api; use devcontainer_api::ensure_devcontainer_cli; use devcontainer_api::read_devcontainer_configuration; use crate::devcontainer_api::DevContainerError; use crate::devcontainer_api::apply_dev_container_template; pub use devcontainer_api::{ DevContainerConfig, find_configs_in_snapshot, find_devcontainer_configs, start_dev_container_with_config, }; pub struct DevContainerContext { pub project_directory: Arc, pub use_podman: bool, pub node_runtime: node_runtime::NodeRuntime, } impl DevContainerContext { pub fn from_workspace(workspace: &Workspace, cx: &App) -> Option { let project_directory = workspace.project().read(cx).active_project_directory(cx)?; let use_podman = DevContainerSettings::get_global(cx).use_podman; let node_runtime = workspace.app_state().node_runtime.clone(); Some(Self { project_directory, use_podman, node_runtime, }) } } #[derive(RegisterSetting)] struct DevContainerSettings { use_podman: bool, } pub fn use_podman(cx: &App) -> bool { DevContainerSettings::get_global(cx).use_podman } impl Settings for DevContainerSettings { fn from_settings(content: &settings::SettingsContent) -> Self { Self { use_podman: content.remote.use_podman.unwrap_or(false), } } } #[derive(PartialEq, Clone, Deserialize, Default, Action)] #[action(namespace = projects)] #[serde(deny_unknown_fields)] struct InitializeDevContainer; pub fn init(cx: &mut App) { cx.on_action(|_: &InitializeDevContainer, cx| { with_active_or_new_workspace(cx, move |workspace, window, cx| { let weak_entity = cx.weak_entity(); workspace.toggle_modal(window, cx, |window, cx| { DevContainerModal::new(weak_entity, window, cx) }); }); }); } #[derive(Clone)] struct TemplateEntry { template: DevContainerTemplate, options_selected: HashMap, current_option_index: usize, current_option: Option, features_selected: HashSet, } #[derive(Clone)] struct FeatureEntry { feature: DevContainerFeature, toggle_state: ToggleState, } #[derive(Clone)] struct TemplateOptionSelection { option_name: String, description: String, navigable_options: Vec<(String, NavigableEntry)>, } impl Eq for TemplateEntry {} impl PartialEq for TemplateEntry { fn eq(&self, other: &Self) -> bool { self.template == other.template } } impl Debug for TemplateEntry { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("TemplateEntry") .field("template", &self.template) .finish() } } impl Eq for FeatureEntry {} impl PartialEq for FeatureEntry { fn eq(&self, other: &Self) -> bool { self.feature == other.feature } } impl Debug for FeatureEntry { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("FeatureEntry") .field("feature", &self.feature) .finish() } } #[derive(Debug, Clone, PartialEq, Eq)] enum DevContainerState { Initial, QueryingTemplates, TemplateQueryReturned(Result, String>), QueryingFeatures(TemplateEntry), FeaturesQueryReturned(TemplateEntry), UserOptionsSpecifying(TemplateEntry), ConfirmingWriteDevContainer(TemplateEntry), TemplateWriteFailed(DevContainerError), } #[derive(Debug, Clone)] enum DevContainerMessage { SearchTemplates, TemplatesRetrieved(Vec), ErrorRetrievingTemplates(String), TemplateSelected(TemplateEntry), TemplateOptionsSpecified(TemplateEntry), TemplateOptionsCompleted(TemplateEntry), FeaturesRetrieved(Vec), FeaturesSelected(TemplateEntry), NeedConfirmWriteDevContainer(TemplateEntry), ConfirmWriteDevContainer(TemplateEntry), FailedToWriteTemplate(DevContainerError), GoBack, } struct DevContainerModal { workspace: WeakEntity, picker: Option>>, features_picker: Option>>, focus_handle: FocusHandle, confirm_entry: NavigableEntry, back_entry: NavigableEntry, state: DevContainerState, } struct TemplatePickerDelegate { selected_index: usize, placeholder_text: String, stateful_modal: WeakEntity, candidate_templates: Vec, matching_indices: Vec, on_confirm: Box< dyn FnMut( TemplateEntry, &mut DevContainerModal, &mut Window, &mut Context, ), >, } impl TemplatePickerDelegate { fn new( placeholder_text: String, stateful_modal: WeakEntity, elements: Vec, on_confirm: Box< dyn FnMut( TemplateEntry, &mut DevContainerModal, &mut Window, &mut Context, ), >, ) -> Self { Self { selected_index: 0, placeholder_text, stateful_modal, candidate_templates: elements, matching_indices: Vec::new(), on_confirm, } } } impl PickerDelegate for TemplatePickerDelegate { type ListItem = AnyElement; fn match_count(&self) -> usize { self.matching_indices.len() } fn selected_index(&self) -> usize { self.selected_index } fn set_selected_index( &mut self, ix: usize, _window: &mut Window, _cx: &mut Context>, ) { self.selected_index = ix; } fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { self.placeholder_text.clone().into() } fn update_matches( &mut self, query: String, _window: &mut Window, _cx: &mut Context>, ) -> gpui::Task<()> { self.matching_indices = self .candidate_templates .iter() .enumerate() .filter(|(_, template_entry)| { template_entry .template .id .to_lowercase() .contains(&query.to_lowercase()) || template_entry .template .name .to_lowercase() .contains(&query.to_lowercase()) }) .map(|(ix, _)| ix) .collect(); self.selected_index = std::cmp::min( self.selected_index, self.matching_indices.len().saturating_sub(1), ); Task::ready(()) } fn confirm( &mut self, _secondary: bool, window: &mut Window, cx: &mut Context>, ) { let fun = &mut self.on_confirm; if self.matching_indices.is_empty() { return; } self.stateful_modal .update(cx, |modal, cx| { let Some(confirmed_entry) = self .matching_indices .get(self.selected_index) .and_then(|ix| self.candidate_templates.get(*ix)) else { log::error!("Selected index not in range of known matches"); return; }; fun(confirmed_entry.clone(), modal, window, cx); }) .ok(); } fn dismissed(&mut self, window: &mut Window, cx: &mut Context>) { self.stateful_modal .update(cx, |modal, cx| { modal.dismiss(&menu::Cancel, window, cx); }) .ok(); } fn render_match( &self, ix: usize, selected: bool, _window: &mut Window, _cx: &mut Context>, ) -> Option { let Some(template_entry) = self.candidate_templates.get(self.matching_indices[ix]) else { return None; }; Some( ListItem::new("li-template-match") .inset(true) .spacing(ui::ListItemSpacing::Sparse) .start_slot(Icon::new(IconName::Box)) .toggle_state(selected) .child(Label::new(template_entry.template.name.clone())) .into_any_element(), ) } fn render_footer( &self, _window: &mut Window, cx: &mut Context>, ) -> Option { Some( h_flex() .w_full() .p_1p5() .gap_1() .justify_start() .border_t_1() .border_color(cx.theme().colors().border_variant) .child( Button::new("run-action", "Continue") .key_binding( KeyBinding::for_action(&menu::Confirm, cx) .map(|kb| kb.size(rems_from_px(12.))), ) .on_click(|_, window, cx| { window.dispatch_action(menu::Confirm.boxed_clone(), cx) }), ) .into_any_element(), ) } } struct FeaturePickerDelegate { selected_index: usize, placeholder_text: String, stateful_modal: WeakEntity, candidate_features: Vec, template_entry: TemplateEntry, matching_indices: Vec, on_confirm: Box< dyn FnMut( TemplateEntry, &mut DevContainerModal, &mut Window, &mut Context, ), >, } impl FeaturePickerDelegate { fn new( placeholder_text: String, stateful_modal: WeakEntity, candidate_features: Vec, template_entry: TemplateEntry, on_confirm: Box< dyn FnMut( TemplateEntry, &mut DevContainerModal, &mut Window, &mut Context, ), >, ) -> Self { Self { selected_index: 0, placeholder_text, stateful_modal, candidate_features, template_entry, matching_indices: Vec::new(), on_confirm, } } } impl PickerDelegate for FeaturePickerDelegate { type ListItem = AnyElement; fn match_count(&self) -> usize { self.matching_indices.len() } fn selected_index(&self) -> usize { self.selected_index } fn set_selected_index( &mut self, ix: usize, _window: &mut Window, _cx: &mut Context>, ) { self.selected_index = ix; } fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { self.placeholder_text.clone().into() } fn update_matches( &mut self, query: String, _window: &mut Window, _cx: &mut Context>, ) -> Task<()> { self.matching_indices = self .candidate_features .iter() .enumerate() .filter(|(_, feature_entry)| { feature_entry .feature .id .to_lowercase() .contains(&query.to_lowercase()) || feature_entry .feature .name .to_lowercase() .contains(&query.to_lowercase()) }) .map(|(ix, _)| ix) .collect(); self.selected_index = std::cmp::min( self.selected_index, self.matching_indices.len().saturating_sub(1), ); Task::ready(()) } fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) { if secondary { self.stateful_modal .update(cx, |modal, cx| { (self.on_confirm)(self.template_entry.clone(), modal, window, cx) }) .ok(); } else { if self.matching_indices.is_empty() { return; } let Some(current) = self .matching_indices .get(self.selected_index) .and_then(|ix| self.candidate_features.get_mut(*ix)) else { log::error!("Selected index not in range of matches"); return; }; current.toggle_state = match current.toggle_state { ToggleState::Selected => { self.template_entry .features_selected .remove(¤t.feature); ToggleState::Unselected } _ => { self.template_entry .features_selected .insert(current.feature.clone()); ToggleState::Selected } }; } } fn dismissed(&mut self, window: &mut Window, cx: &mut Context>) { self.stateful_modal .update(cx, |modal, cx| { modal.dismiss(&menu::Cancel, window, cx); }) .ok(); } fn render_match( &self, ix: usize, selected: bool, _window: &mut Window, _cx: &mut Context>, ) -> Option { let feature_entry = self.candidate_features[self.matching_indices[ix]].clone(); Some( ListItem::new("li-what") .inset(true) .toggle_state(selected) .start_slot(Switch::new( feature_entry.feature.id.clone(), feature_entry.toggle_state, )) .child(Label::new(feature_entry.feature.name)) .into_any_element(), ) } fn render_footer( &self, _window: &mut Window, cx: &mut Context>, ) -> Option { Some( h_flex() .w_full() .p_1p5() .gap_1() .justify_start() .border_t_1() .border_color(cx.theme().colors().border_variant) .child( Button::new("run-action", "Select Feature") .key_binding( KeyBinding::for_action(&menu::Confirm, cx) .map(|kb| kb.size(rems_from_px(12.))), ) .on_click(|_, window, cx| { window.dispatch_action(menu::Confirm.boxed_clone(), cx) }), ) .child( Button::new("run-action-secondary", "Confirm Selections") .key_binding( KeyBinding::for_action(&menu::SecondaryConfirm, cx) .map(|kb| kb.size(rems_from_px(12.))), ) .on_click(|_, window, cx| { window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx) }), ) .into_any_element(), ) } } impl DevContainerModal { fn new(workspace: WeakEntity, _window: &mut Window, cx: &mut App) -> Self { DevContainerModal { workspace, picker: None, features_picker: None, state: DevContainerState::Initial, focus_handle: cx.focus_handle(), confirm_entry: NavigableEntry::focusable(cx), back_entry: NavigableEntry::focusable(cx), } } fn render_initial(&self, window: &mut Window, cx: &mut Context) -> AnyElement { let mut view = Navigable::new( div() .p_1() .child( div().track_focus(&self.focus_handle).child( ModalHeader::new().child( Headline::new("Create Dev Container").size(HeadlineSize::XSmall), ), ), ) .child(ListSeparator) .child( div() .track_focus(&self.confirm_entry.focus_handle) .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| { this.accept_message(DevContainerMessage::SearchTemplates, window, cx); })) .child( ListItem::new("li-search-containers") .inset(true) .spacing(ui::ListItemSpacing::Sparse) .start_slot( Icon::new(IconName::MagnifyingGlass).color(Color::Muted), ) .toggle_state( self.confirm_entry.focus_handle.contains_focused(window, cx), ) .on_click(cx.listener(|this, _, window, cx| { this.accept_message( DevContainerMessage::SearchTemplates, window, cx, ); cx.notify(); })) .child(Label::new("Search for Dev Container Templates")), ), ) .into_any_element(), ); view = view.entry(self.confirm_entry.clone()); view.render(window, cx).into_any_element() } fn render_error( &self, error_title: String, error: impl Display, _window: &mut Window, _cx: &mut Context, ) -> AnyElement { v_flex() .p_1() .child(div().track_focus(&self.focus_handle).child( ModalHeader::new().child(Headline::new(error_title).size(HeadlineSize::XSmall)), )) .child(ListSeparator) .child( v_flex() .child(Label::new(format!("{}", error))) .whitespace_normal(), ) .into_any_element() } fn render_retrieved_templates( &self, window: &mut Window, cx: &mut Context, ) -> AnyElement { if let Some(picker) = &self.picker { let picker_element = div() .track_focus(&self.focus_handle(cx)) .child(picker.clone().into_any_element()) .into_any_element(); picker.focus_handle(cx).focus(window, cx); picker_element } else { div().into_any_element() } } fn render_user_options_specifying( &self, template_entry: TemplateEntry, window: &mut Window, cx: &mut Context, ) -> AnyElement { let Some(next_option_entries) = &template_entry.current_option else { return div().into_any_element(); }; let mut view = Navigable::new( div() .child( div() .id("title") .tooltip(Tooltip::text(next_option_entries.description.clone())) .track_focus(&self.focus_handle) .child( ModalHeader::new() .child( Headline::new("Template Option: ").size(HeadlineSize::XSmall), ) .child( Headline::new(&next_option_entries.option_name) .size(HeadlineSize::XSmall), ), ), ) .child(ListSeparator) .children( next_option_entries .navigable_options .iter() .map(|(option, entry)| { div() .id(format!("li-parent-{}", option)) .track_focus(&entry.focus_handle) .on_action({ let mut template = template_entry.clone(); template.options_selected.insert( next_option_entries.option_name.clone(), option.clone(), ); cx.listener(move |this, _: &menu::Confirm, window, cx| { this.accept_message( DevContainerMessage::TemplateOptionsSpecified( template.clone(), ), window, cx, ); }) }) .child( ListItem::new(format!("li-option-{}", option)) .inset(true) .spacing(ui::ListItemSpacing::Sparse) .toggle_state( entry.focus_handle.contains_focused(window, cx), ) .on_click({ let mut template = template_entry.clone(); template.options_selected.insert( next_option_entries.option_name.clone(), option.clone(), ); cx.listener(move |this, _, window, cx| { this.accept_message( DevContainerMessage::TemplateOptionsSpecified( template.clone(), ), window, cx, ); cx.notify(); }) }) .child(Label::new(option)), ) }), ) .child(ListSeparator) .child( div() .track_focus(&self.back_entry.focus_handle) .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| { this.accept_message(DevContainerMessage::GoBack, window, cx); })) .child( ListItem::new("li-goback") .inset(true) .spacing(ui::ListItemSpacing::Sparse) .start_slot(Icon::new(IconName::Return).color(Color::Muted)) .toggle_state( self.back_entry.focus_handle.contains_focused(window, cx), ) .on_click(cx.listener(|this, _, window, cx| { this.accept_message(DevContainerMessage::GoBack, window, cx); cx.notify(); })) .child(Label::new("Go Back")), ), ) .into_any_element(), ); for (_, entry) in &next_option_entries.navigable_options { view = view.entry(entry.clone()); } view = view.entry(self.back_entry.clone()); view.render(window, cx).into_any_element() } fn render_features_query_returned( &self, window: &mut Window, cx: &mut Context, ) -> AnyElement { if let Some(picker) = &self.features_picker { let picker_element = div() .track_focus(&self.focus_handle(cx)) .child(picker.clone().into_any_element()) .into_any_element(); picker.focus_handle(cx).focus(window, cx); picker_element } else { div().into_any_element() } } fn render_confirming_write_dev_container( &self, template_entry: TemplateEntry, window: &mut Window, cx: &mut Context, ) -> AnyElement { Navigable::new( div() .child( div().track_focus(&self.focus_handle).child( ModalHeader::new() .icon(Icon::new(IconName::Warning).color(Color::Warning)) .child( Headline::new("Overwrite Existing Configuration?") .size(HeadlineSize::XSmall), ), ), ) .child( div() .track_focus(&self.confirm_entry.focus_handle) .on_action({ let template = template_entry.clone(); cx.listener(move |this, _: &menu::Confirm, window, cx| { this.accept_message( DevContainerMessage::ConfirmWriteDevContainer(template.clone()), window, cx, ); }) }) .child( ListItem::new("li-search-containers") .inset(true) .spacing(ui::ListItemSpacing::Sparse) .start_slot(Icon::new(IconName::Check).color(Color::Muted)) .toggle_state( self.confirm_entry.focus_handle.contains_focused(window, cx), ) .on_click(cx.listener(move |this, _, window, cx| { this.accept_message( DevContainerMessage::ConfirmWriteDevContainer( template_entry.clone(), ), window, cx, ); cx.notify(); })) .child(Label::new("Overwrite")), ), ) .child( div() .track_focus(&self.back_entry.focus_handle) .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| { this.dismiss(&menu::Cancel, window, cx); })) .child( ListItem::new("li-goback") .inset(true) .spacing(ui::ListItemSpacing::Sparse) .start_slot(Icon::new(IconName::XCircle).color(Color::Muted)) .toggle_state( self.back_entry.focus_handle.contains_focused(window, cx), ) .on_click(cx.listener(|this, _, window, cx| { this.dismiss(&menu::Cancel, window, cx); cx.notify(); })) .child(Label::new("Cancel")), ), ) .into_any_element(), ) .entry(self.confirm_entry.clone()) .entry(self.back_entry.clone()) .render(window, cx) .into_any_element() } fn render_querying_templates(&self, window: &mut Window, cx: &mut Context) -> AnyElement { Navigable::new( div() .child( div().track_focus(&self.focus_handle).child( ModalHeader::new().child( Headline::new("Create Dev Container").size(HeadlineSize::XSmall), ), ), ) .child(ListSeparator) .child( div().child( ListItem::new("li-querying") .inset(true) .spacing(ui::ListItemSpacing::Sparse) .start_slot( Icon::new(IconName::ArrowCircle) .color(Color::Muted) .with_rotate_animation(2), ) .child(Label::new("Querying template registry...")), ), ) .child(ListSeparator) .child( div() .track_focus(&self.back_entry.focus_handle) .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| { this.accept_message(DevContainerMessage::GoBack, window, cx); })) .child( ListItem::new("li-goback") .inset(true) .spacing(ui::ListItemSpacing::Sparse) .start_slot(Icon::new(IconName::Pencil).color(Color::Muted)) .toggle_state( self.back_entry.focus_handle.contains_focused(window, cx), ) .on_click(cx.listener(|this, _, window, cx| { this.accept_message(DevContainerMessage::GoBack, window, cx); cx.notify(); })) .child(Label::new("Go Back")), ), ) .into_any_element(), ) .entry(self.back_entry.clone()) .render(window, cx) .into_any_element() } fn render_querying_features(&self, window: &mut Window, cx: &mut Context) -> AnyElement { Navigable::new( div() .child( div().track_focus(&self.focus_handle).child( ModalHeader::new().child( Headline::new("Create Dev Container").size(HeadlineSize::XSmall), ), ), ) .child(ListSeparator) .child( div().child( ListItem::new("li-querying") .inset(true) .spacing(ui::ListItemSpacing::Sparse) .start_slot( Icon::new(IconName::ArrowCircle) .color(Color::Muted) .with_rotate_animation(2), ) .child(Label::new("Querying features...")), ), ) .child(ListSeparator) .child( div() .track_focus(&self.back_entry.focus_handle) .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| { this.accept_message(DevContainerMessage::GoBack, window, cx); })) .child( ListItem::new("li-goback") .inset(true) .spacing(ui::ListItemSpacing::Sparse) .start_slot(Icon::new(IconName::Pencil).color(Color::Muted)) .toggle_state( self.back_entry.focus_handle.contains_focused(window, cx), ) .on_click(cx.listener(|this, _, window, cx| { this.accept_message(DevContainerMessage::GoBack, window, cx); cx.notify(); })) .child(Label::new("Go Back")), ), ) .into_any_element(), ) .entry(self.back_entry.clone()) .render(window, cx) .into_any_element() } } impl StatefulModal for DevContainerModal { type State = DevContainerState; type Message = DevContainerMessage; fn state(&self) -> Self::State { self.state.clone() } fn render_for_state( &self, state: Self::State, window: &mut Window, cx: &mut Context, ) -> AnyElement { match state { DevContainerState::Initial => self.render_initial(window, cx), DevContainerState::QueryingTemplates => self.render_querying_templates(window, cx), DevContainerState::TemplateQueryReturned(Ok(_)) => { self.render_retrieved_templates(window, cx) } DevContainerState::UserOptionsSpecifying(template_entry) => { self.render_user_options_specifying(template_entry, window, cx) } DevContainerState::QueryingFeatures(_) => self.render_querying_features(window, cx), DevContainerState::FeaturesQueryReturned(_) => { self.render_features_query_returned(window, cx) } DevContainerState::ConfirmingWriteDevContainer(template_entry) => { self.render_confirming_write_dev_container(template_entry, window, cx) } DevContainerState::TemplateWriteFailed(dev_container_error) => self.render_error( "Error Creating Dev Container Definition".to_string(), dev_container_error, window, cx, ), DevContainerState::TemplateQueryReturned(Err(e)) => { self.render_error("Error Retrieving Templates".to_string(), e, window, cx) } } } fn accept_message( &mut self, message: Self::Message, window: &mut Window, cx: &mut Context, ) { let new_state = match message { DevContainerMessage::SearchTemplates => { cx.spawn_in(window, async move |this, cx| { let Ok(client) = cx.update(|_, cx| cx.http_client()) else { return; }; match get_templates(client).await { Ok(templates) => { let message = DevContainerMessage::TemplatesRetrieved(templates.templates); this.update_in(cx, |this, window, cx| { this.accept_message(message, window, cx); }) .ok(); } Err(e) => { let message = DevContainerMessage::ErrorRetrievingTemplates(e); this.update_in(cx, |this, window, cx| { this.accept_message(message, window, cx); }) .ok(); } } }) .detach(); Some(DevContainerState::QueryingTemplates) } DevContainerMessage::ErrorRetrievingTemplates(message) => { Some(DevContainerState::TemplateQueryReturned(Err(message))) } DevContainerMessage::GoBack => match &self.state { DevContainerState::Initial => Some(DevContainerState::Initial), DevContainerState::QueryingTemplates => Some(DevContainerState::Initial), DevContainerState::UserOptionsSpecifying(template_entry) => { if template_entry.current_option_index <= 1 { self.accept_message(DevContainerMessage::SearchTemplates, window, cx); } else { let mut template_entry = template_entry.clone(); template_entry.current_option_index = template_entry.current_option_index.saturating_sub(2); self.accept_message( DevContainerMessage::TemplateOptionsSpecified(template_entry), window, cx, ); } None } _ => Some(DevContainerState::Initial), }, DevContainerMessage::TemplatesRetrieved(items) => { let items = items .into_iter() .map(|item| TemplateEntry { template: item, options_selected: HashMap::new(), current_option_index: 0, current_option: None, features_selected: HashSet::new(), }) .collect::>(); if self.state == DevContainerState::QueryingTemplates { let delegate = TemplatePickerDelegate::new( "Select a template".to_string(), cx.weak_entity(), items.clone(), Box::new(|entry, this, window, cx| { this.accept_message( DevContainerMessage::TemplateSelected(entry), window, cx, ); }), ); let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false)); self.picker = Some(picker); Some(DevContainerState::TemplateQueryReturned(Ok(items))) } else { None } } DevContainerMessage::TemplateSelected(mut template_entry) => { let Some(options) = template_entry.template.clone().options else { return self.accept_message( DevContainerMessage::TemplateOptionsCompleted(template_entry), window, cx, ); }; let options = options .iter() .collect::>() .clone(); let Some((first_option_name, first_option)) = options.get(template_entry.current_option_index) else { return self.accept_message( DevContainerMessage::TemplateOptionsCompleted(template_entry), window, cx, ); }; let next_option_entries = first_option .possible_values() .into_iter() .map(|option| (option, NavigableEntry::focusable(cx))) .collect(); template_entry.current_option_index += 1; template_entry.current_option = Some(TemplateOptionSelection { option_name: (*first_option_name).clone(), description: first_option .description .clone() .unwrap_or_else(|| "".to_string()), navigable_options: next_option_entries, }); Some(DevContainerState::UserOptionsSpecifying(template_entry)) } DevContainerMessage::TemplateOptionsSpecified(mut template_entry) => { let Some(options) = template_entry.template.clone().options else { return self.accept_message( DevContainerMessage::TemplateOptionsCompleted(template_entry), window, cx, ); }; let options = options .iter() .collect::>() .clone(); let Some((next_option_name, next_option)) = options.get(template_entry.current_option_index) else { return self.accept_message( DevContainerMessage::TemplateOptionsCompleted(template_entry), window, cx, ); }; let next_option_entries = next_option .possible_values() .into_iter() .map(|option| (option, NavigableEntry::focusable(cx))) .collect(); template_entry.current_option_index += 1; template_entry.current_option = Some(TemplateOptionSelection { option_name: (*next_option_name).clone(), description: next_option .description .clone() .unwrap_or_else(|| "".to_string()), navigable_options: next_option_entries, }); Some(DevContainerState::UserOptionsSpecifying(template_entry)) } DevContainerMessage::TemplateOptionsCompleted(template_entry) => { cx.spawn_in(window, async move |this, cx| { let Ok(client) = cx.update(|_, cx| cx.http_client()) else { return; }; let Some(features) = get_features(client).await.log_err() else { return; }; let message = DevContainerMessage::FeaturesRetrieved(features.features); this.update_in(cx, |this, window, cx| { this.accept_message(message, window, cx); }) .ok(); }) .detach(); Some(DevContainerState::QueryingFeatures(template_entry)) } DevContainerMessage::FeaturesRetrieved(features) => { if let DevContainerState::QueryingFeatures(template_entry) = self.state.clone() { let features = features .iter() .map(|feature| FeatureEntry { feature: feature.clone(), toggle_state: ToggleState::Unselected, }) .collect::>(); let delegate = FeaturePickerDelegate::new( "Select features to add".to_string(), cx.weak_entity(), features, template_entry.clone(), Box::new(|entry, this, window, cx| { this.accept_message( DevContainerMessage::FeaturesSelected(entry), window, cx, ); }), ); let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false)); self.features_picker = Some(picker); Some(DevContainerState::FeaturesQueryReturned(template_entry)) } else { None } } DevContainerMessage::FeaturesSelected(template_entry) => { if let Some(workspace) = self.workspace.upgrade() { dispatch_apply_templates(template_entry, workspace, window, true, cx); } None } DevContainerMessage::NeedConfirmWriteDevContainer(template_entry) => Some( DevContainerState::ConfirmingWriteDevContainer(template_entry), ), DevContainerMessage::ConfirmWriteDevContainer(template_entry) => { if let Some(workspace) = self.workspace.upgrade() { dispatch_apply_templates(template_entry, workspace, window, false, cx); } None } DevContainerMessage::FailedToWriteTemplate(error) => { Some(DevContainerState::TemplateWriteFailed(error)) } }; if let Some(state) = new_state { self.state = state; self.focus_handle.focus(window, cx); } cx.notify(); } } impl EventEmitter for DevContainerModal {} impl Focusable for DevContainerModal { fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone() } } impl ModalView for DevContainerModal {} impl Render for DevContainerModal { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { self.render_inner(window, cx) } } trait StatefulModal: ModalView + EventEmitter + Render { type State; type Message; fn state(&self) -> Self::State; fn render_for_state( &self, state: Self::State, window: &mut Window, cx: &mut Context, ) -> AnyElement; fn accept_message( &mut self, message: Self::Message, window: &mut Window, cx: &mut Context, ); fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) { cx.emit(DismissEvent); } fn render_inner(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let element = self.render_for_state(self.state(), window, cx); div() .elevation_3(cx) .w(rems(34.)) .key_context("ContainerModal") .on_action(cx.listener(Self::dismiss)) .child(element) } } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct GithubTokenResponse { token: String, } fn ghcr_url() -> &'static str { "https://ghcr.io" } fn ghcr_domain() -> &'static str { "ghcr.io" } fn devcontainer_templates_repository() -> &'static str { "devcontainers/templates" } fn devcontainer_features_repository() -> &'static str { "devcontainers/features" } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct ManifestLayer { digest: String, } #[derive(Debug, Deserialize, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] struct TemplateOptions { #[serde(rename = "type")] option_type: String, description: Option, proposals: Option>, #[serde(rename = "enum")] enum_values: Option>, // Different repositories surface "default: 'true'" or "default: true", // so we need to be flexible in deserializing #[serde(deserialize_with = "deserialize_string_or_bool")] default: String, } fn deserialize_string_or_bool<'de, D>(deserializer: D) -> Result where D: serde::Deserializer<'de>, { use serde::Deserialize; #[derive(Deserialize)] #[serde(untagged)] enum StringOrBool { String(String), Bool(bool), } match StringOrBool::deserialize(deserializer)? { StringOrBool::String(s) => Ok(s), StringOrBool::Bool(b) => Ok(b.to_string()), } } impl TemplateOptions { fn possible_values(&self) -> Vec { match self.option_type.as_str() { "string" => self .enum_values .clone() .or(self.proposals.clone().or(Some(vec![self.default.clone()]))) .unwrap_or_default(), // If not string, must be boolean _ => { if self.default == "true" { vec!["true".to_string(), "false".to_string()] } else { vec!["false".to_string(), "true".to_string()] } } } } } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct DockerManifestsResponse { layers: Vec, } #[derive(Debug, Deserialize, Clone, PartialEq, Eq, Hash)] #[serde(rename_all = "camelCase")] struct DevContainerFeature { id: String, version: String, name: String, source_repository: Option, } impl DevContainerFeature { fn major_version(&self) -> String { let Some(mv) = self.version.get(..1) else { return "".to_string(); }; mv.to_string() } } #[derive(Debug, Deserialize, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] struct DevContainerTemplate { id: String, name: String, options: Option>, source_repository: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct DevContainerFeaturesResponse { features: Vec, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct DevContainerTemplatesResponse { templates: Vec, } fn dispatch_apply_templates( template_entry: TemplateEntry, workspace: Entity, window: &mut Window, check_for_existing: bool, cx: &mut Context, ) { cx.spawn_in(window, async move |this, cx| { let Some((tree_id, context)) = workspace.update(cx, |workspace, cx| { let worktree = workspace .project() .read(cx) .visible_worktrees(cx) .find_map(|tree| { tree.read(cx) .root_entry()? .is_dir() .then_some(tree.read(cx)) }); let tree_id = worktree.map(|w| w.id())?; let context = DevContainerContext::from_workspace(workspace, cx)?; Some((tree_id, context)) }) else { return; }; let Ok(cli) = ensure_devcontainer_cli(&context.node_runtime).await else { this.update_in(cx, |this, window, cx| { this.accept_message( DevContainerMessage::FailedToWriteTemplate( DevContainerError::DevContainerCliNotAvailable, ), window, cx, ); }) .log_err(); return; }; { if check_for_existing && read_devcontainer_configuration(&context, &cli, None) .await .is_ok() { this.update_in(cx, |this, window, cx| { this.accept_message( DevContainerMessage::NeedConfirmWriteDevContainer(template_entry), window, cx, ); }) .ok(); return; } let files = match apply_dev_container_template( &template_entry.template, &template_entry.options_selected, &template_entry.features_selected, &context, &cli, ) .await { Ok(files) => files, Err(e) => { this.update_in(cx, |this, window, cx| { this.accept_message( DevContainerMessage::FailedToWriteTemplate(e), window, cx, ); }) .ok(); return; } }; if files .files .contains(&"./.devcontainer/devcontainer.json".to_string()) { let Some(workspace_task) = workspace .update_in(cx, |workspace, window, cx| { let Ok(path) = RelPath::unix(".devcontainer/devcontainer.json") else { return Task::ready(Err(anyhow!( "Couldn't create path for .devcontainer/devcontainer.json" ))); }; workspace.open_path((tree_id, path), None, true, window, cx) }) .ok() else { return; }; workspace_task.await.log_err(); } this.update_in(cx, |this, window, cx| { this.dismiss(&menu::Cancel, window, cx); }) .ok(); } }) .detach(); } async fn get_templates( client: Arc, ) -> Result { let token = get_ghcr_token(&client).await?; let manifest = get_latest_manifest(&token.token, &client).await?; let mut template_response = get_devcontainer_templates(&token.token, &manifest.layers[0].digest, &client).await?; for template in &mut template_response.templates { template.source_repository = Some(format!( "{}/{}", ghcr_domain(), devcontainer_templates_repository() )); } Ok(template_response) } async fn get_features(client: Arc) -> Result { let token = get_ghcr_token(&client).await?; let manifest = get_latest_feature_manifest(&token.token, &client).await?; let mut features_response = get_devcontainer_features(&token.token, &manifest.layers[0].digest, &client).await?; for feature in &mut features_response.features { feature.source_repository = Some(format!( "{}/{}", ghcr_domain(), devcontainer_features_repository() )); } Ok(features_response) } async fn get_ghcr_token(client: &Arc) -> Result { let url = format!( "{}/token?service=ghcr.io&scope=repository:{}:pull", ghcr_url(), devcontainer_templates_repository() ); get_deserialized_response("", &url, client).await } async fn get_latest_feature_manifest( token: &str, client: &Arc, ) -> Result { let url = format!( "{}/v2/{}/manifests/latest", ghcr_url(), devcontainer_features_repository() ); get_deserialized_response(token, &url, client).await } async fn get_latest_manifest( token: &str, client: &Arc, ) -> Result { let url = format!( "{}/v2/{}/manifests/latest", ghcr_url(), devcontainer_templates_repository() ); get_deserialized_response(token, &url, client).await } async fn get_devcontainer_features( token: &str, blob_digest: &str, client: &Arc, ) -> Result { let url = format!( "{}/v2/{}/blobs/{}", ghcr_url(), devcontainer_features_repository(), blob_digest ); get_deserialized_response(token, &url, client).await } async fn get_devcontainer_templates( token: &str, blob_digest: &str, client: &Arc, ) -> Result { let url = format!( "{}/v2/{}/blobs/{}", ghcr_url(), devcontainer_templates_repository(), blob_digest ); get_deserialized_response(token, &url, client).await } async fn get_deserialized_response( token: &str, url: &str, client: &Arc, ) -> Result where T: for<'de> Deserialize<'de>, { let request = match Request::get(url) .header("Authorization", format!("Bearer {}", token)) .header("Accept", "application/vnd.oci.image.manifest.v1+json") .body(AsyncBody::default()) { Ok(request) => request, Err(e) => return Err(format!("Failed to create request: {}", e)), }; let response = match client.send(request).await { Ok(response) => response, Err(e) => { return Err(format!("Failed to send request: {}", e)); } }; let mut output = String::new(); if let Err(e) = response.into_body().read_to_string(&mut output).await { return Err(format!("Failed to read response body: {}", e)); }; match serde_json::from_str(&output) { Ok(response) => Ok(response), Err(e) => Err(format!("Failed to deserialize response: {}", e)), } } #[cfg(test)] mod tests { use gpui::TestAppContext; use http_client::{FakeHttpClient, anyhow}; use crate::{ GithubTokenResponse, devcontainer_templates_repository, get_deserialized_response, get_devcontainer_templates, get_ghcr_token, get_latest_manifest, }; #[gpui::test] async fn test_get_deserialized_response(_cx: &mut TestAppContext) { let client = FakeHttpClient::create(|_request| async move { Ok(http_client::Response::builder() .status(200) .body("{ \"token\": \"thisisatoken\" }".into()) .unwrap()) }); let response = get_deserialized_response::("", "https://ghcr.io/token", &client) .await; assert!(response.is_ok()); assert_eq!(response.unwrap().token, "thisisatoken".to_string()) } #[gpui::test] async fn test_get_ghcr_token() { let client = FakeHttpClient::create(|request| async move { let host = request.uri().host(); if host.is_none() || host.unwrap() != "ghcr.io" { return Err(anyhow!("Unexpected host: {}", host.unwrap_or_default())); } let path = request.uri().path(); if path != "/token" { return Err(anyhow!("Unexpected path: {}", path)); } let query = request.uri().query(); if query.is_none() || query.unwrap() != format!( "service=ghcr.io&scope=repository:{}:pull", devcontainer_templates_repository() ) { return Err(anyhow!("Unexpected query: {}", query.unwrap_or_default())); } Ok(http_client::Response::builder() .status(200) .body("{ \"token\": \"thisisatoken\" }".into()) .unwrap()) }); let response = get_ghcr_token(&client).await; assert!(response.is_ok()); assert_eq!(response.unwrap().token, "thisisatoken".to_string()); } #[gpui::test] async fn test_get_latest_manifests() { let client = FakeHttpClient::create(|request| async move { let host = request.uri().host(); if host.is_none() || host.unwrap() != "ghcr.io" { return Err(anyhow!("Unexpected host: {}", host.unwrap_or_default())); } let path = request.uri().path(); if path != format!( "/v2/{}/manifests/latest", devcontainer_templates_repository() ) { return Err(anyhow!("Unexpected path: {}", path)); } Ok(http_client::Response::builder() .status(200) .body("{ \"schemaVersion\": 2, \"mediaType\": \"application/vnd.oci.image.manifest.v1+json\", \"config\": { \"mediaType\": \"application/vnd.devcontainers\", \"digest\": \"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a\", \"size\": 2 }, \"layers\": [ { \"mediaType\": \"application/vnd.devcontainers.collection.layer.v1+json\", \"digest\": \"sha256:035e9c9fd9bd61f6d3965fa4bf11f3ddfd2490a8cf324f152c13cc3724d67d09\", \"size\": 65235, \"annotations\": { \"org.opencontainers.image.title\": \"devcontainer-collection.json\" } } ], \"annotations\": { \"com.github.package.type\": \"devcontainer_collection\" } }".into()) .unwrap()) }); let response = get_latest_manifest("", &client).await; assert!(response.is_ok()); let response = response.unwrap(); assert_eq!(response.layers.len(), 1); assert_eq!( response.layers[0].digest, "sha256:035e9c9fd9bd61f6d3965fa4bf11f3ddfd2490a8cf324f152c13cc3724d67d09" ); } #[gpui::test] async fn test_get_devcontainer_templates() { let client = FakeHttpClient::create(|request| async move { let host = request.uri().host(); if host.is_none() || host.unwrap() != "ghcr.io" { return Err(anyhow!("Unexpected host: {}", host.unwrap_or_default())); } let path = request.uri().path(); if path != format!( "/v2/{}/blobs/sha256:035e9c9fd9bd61f6d3965fa4bf11f3ddfd2490a8cf324f152c13cc3724d67d09", devcontainer_templates_repository() ) { return Err(anyhow!("Unexpected path: {}", path)); } Ok(http_client::Response::builder() .status(200) .body("{ \"sourceInformation\": { \"source\": \"devcontainer-cli\" }, \"templates\": [ { \"id\": \"alpine\", \"version\": \"3.4.0\", \"name\": \"Alpine\", \"description\": \"Simple Alpine container with Git installed.\", \"documentationURL\": \"https://github.com/devcontainers/templates/tree/main/src/alpine\", \"publisher\": \"Dev Container Spec Maintainers\", \"licenseURL\": \"https://github.com/devcontainers/templates/blob/main/LICENSE\", \"options\": { \"imageVariant\": { \"type\": \"string\", \"description\": \"Alpine version:\", \"proposals\": [ \"3.21\", \"3.20\", \"3.19\", \"3.18\" ], \"default\": \"3.20\" } }, \"platforms\": [ \"Any\" ], \"optionalPaths\": [ \".github/dependabot.yml\" ], \"type\": \"image\", \"files\": [ \"NOTES.md\", \"README.md\", \"devcontainer-template.json\", \".devcontainer/devcontainer.json\", \".github/dependabot.yml\" ], \"fileCount\": 5, \"featureIds\": [] } ] }".into()) .unwrap()) }); let response = get_devcontainer_templates( "", "sha256:035e9c9fd9bd61f6d3965fa4bf11f3ddfd2490a8cf324f152c13cc3724d67d09", &client, ) .await; assert!(response.is_ok()); let response = response.unwrap(); assert_eq!(response.templates.len(), 1); assert_eq!(response.templates[0].name, "Alpine"); } }