mirror of
https://github.com/zed-industries/zed.git
synced 2026-06-01 05:51:14 +00:00
## Context Closes #11473 In-house Zed implementation of devcontainers. Replaces the dependency on the [reference implementation](https://github.com/devcontainers/cli) via Node. This enables additional features with this implementation: 1. Zed extensions can be specified in the `customizations` block, via this syntax in `devcontainer.json: ``` ... "customizations": { "zed": { "extensions": ["vue", "ruby"], }, }, ``` 2. [forwardPorts](https://containers.dev/implementors/json_reference/#general-properties) are supported for multiple ports proxied to the host ## How to Review <!-- Help reviewers focus their attention: - For small PRs: note what to focus on (e.g., "error handling in foo.rs") - For large PRs (>400 LOC): provide a guided tour — numbered list of files/commits to read in order. (The `large-pr` label is applied automatically.) - See the review process guidelines for comment conventions --> ## Self-Review Checklist <!-- Check before requesting review: --> - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Improved devcontainer implementation by moving initialization and creation in-house
1759 lines
62 KiB
Rust
1759 lines
62 KiB
Rust
use std::path::Path;
|
|
|
|
use fs::Fs;
|
|
use gpui::AppContext;
|
|
use gpui::Entity;
|
|
use gpui::Task;
|
|
use gpui::WeakEntity;
|
|
use http_client::anyhow;
|
|
use picker::Picker;
|
|
use picker::PickerDelegate;
|
|
use project::ProjectEnvironment;
|
|
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 util::shell::Shell;
|
|
|
|
use gpui::{Action, DismissEvent, EventEmitter, FocusHandle, Focusable, RenderOnce};
|
|
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 http_client::HttpClient;
|
|
|
|
mod command_json;
|
|
mod devcontainer_api;
|
|
mod devcontainer_json;
|
|
mod devcontainer_manifest;
|
|
mod docker;
|
|
mod features;
|
|
mod oci;
|
|
|
|
use devcontainer_api::read_default_devcontainer_configuration;
|
|
|
|
use crate::devcontainer_api::DevContainerError;
|
|
use crate::devcontainer_api::apply_devcontainer_template;
|
|
use crate::oci::get_deserializable_oci_blob;
|
|
use crate::oci::get_latest_oci_manifest;
|
|
use crate::oci::get_oci_token;
|
|
|
|
pub use devcontainer_api::{
|
|
DevContainerConfig, find_configs_in_snapshot, find_devcontainer_configs,
|
|
start_dev_container_with_config,
|
|
};
|
|
|
|
/// Converts a string to a safe environment variable name.
|
|
///
|
|
/// Mirrors the CLI's `getSafeId` in `containerFeatures.ts`:
|
|
/// replaces non-alphanumeric/underscore characters with `_`, replaces a
|
|
/// leading sequence of digits/underscores with a single `_`, and uppercases.
|
|
pub(crate) fn safe_id_lower(input: &str) -> String {
|
|
get_safe_id(input).to_lowercase()
|
|
}
|
|
pub(crate) fn safe_id_upper(input: &str) -> String {
|
|
get_safe_id(input).to_uppercase()
|
|
}
|
|
fn get_safe_id(input: &str) -> String {
|
|
let replaced: String = input
|
|
.chars()
|
|
.map(|c| {
|
|
if c.is_alphanumeric() || c == '_' {
|
|
c
|
|
} else {
|
|
'_'
|
|
}
|
|
})
|
|
.collect();
|
|
let without_leading = replaced.trim_start_matches(|c: char| c.is_ascii_digit() || c == '_');
|
|
let result = if without_leading.len() < replaced.len() {
|
|
format!("_{}", without_leading)
|
|
} else {
|
|
replaced
|
|
};
|
|
result
|
|
}
|
|
|
|
pub struct DevContainerContext {
|
|
pub project_directory: Arc<Path>,
|
|
pub use_podman: bool,
|
|
pub fs: Arc<dyn Fs>,
|
|
pub http_client: Arc<dyn HttpClient>,
|
|
pub environment: WeakEntity<ProjectEnvironment>,
|
|
}
|
|
|
|
impl DevContainerContext {
|
|
pub fn from_workspace(workspace: &Workspace, cx: &App) -> Option<Self> {
|
|
let project_directory = workspace.project().read(cx).active_project_directory(cx)?;
|
|
let use_podman = DevContainerSettings::get_global(cx).use_podman;
|
|
let http_client = cx.http_client().clone();
|
|
let fs = workspace.app_state().fs.clone();
|
|
let environment = workspace.project().read(cx).environment().downgrade();
|
|
Some(Self {
|
|
project_directory,
|
|
use_podman,
|
|
fs,
|
|
http_client,
|
|
environment,
|
|
})
|
|
}
|
|
|
|
pub async fn environment(&self, cx: &mut impl AppContext) -> HashMap<String, String> {
|
|
let Ok(task) = self.environment.update(cx, |this, cx| {
|
|
this.local_directory_environment(&Shell::System, self.project_directory.clone(), cx)
|
|
}) else {
|
|
return HashMap::default();
|
|
};
|
|
task.await
|
|
.map(|env| env.into_iter().collect::<std::collections::HashMap<_, _>>())
|
|
.unwrap_or_default()
|
|
}
|
|
}
|
|
|
|
#[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<String, String>,
|
|
current_option_index: usize,
|
|
current_option: Option<TemplateOptionSelection>,
|
|
features_selected: HashSet<DevContainerFeature>,
|
|
}
|
|
|
|
#[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<Vec<TemplateEntry>, String>),
|
|
QueryingFeatures(TemplateEntry),
|
|
FeaturesQueryReturned(TemplateEntry),
|
|
UserOptionsSpecifying(TemplateEntry),
|
|
ConfirmingWriteDevContainer(TemplateEntry),
|
|
TemplateWriteFailed(DevContainerError),
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
enum DevContainerMessage {
|
|
SearchTemplates,
|
|
TemplatesRetrieved(Vec<DevContainerTemplate>),
|
|
ErrorRetrievingTemplates(String),
|
|
TemplateSelected(TemplateEntry),
|
|
TemplateOptionsSpecified(TemplateEntry),
|
|
TemplateOptionsCompleted(TemplateEntry),
|
|
FeaturesRetrieved(Vec<DevContainerFeature>),
|
|
FeaturesSelected(TemplateEntry),
|
|
NeedConfirmWriteDevContainer(TemplateEntry),
|
|
ConfirmWriteDevContainer(TemplateEntry),
|
|
FailedToWriteTemplate(DevContainerError),
|
|
GoBack,
|
|
}
|
|
|
|
struct DevContainerModal {
|
|
workspace: WeakEntity<Workspace>,
|
|
picker: Option<Entity<Picker<TemplatePickerDelegate>>>,
|
|
features_picker: Option<Entity<Picker<FeaturePickerDelegate>>>,
|
|
focus_handle: FocusHandle,
|
|
confirm_entry: NavigableEntry,
|
|
back_entry: NavigableEntry,
|
|
state: DevContainerState,
|
|
}
|
|
|
|
struct TemplatePickerDelegate {
|
|
selected_index: usize,
|
|
placeholder_text: String,
|
|
stateful_modal: WeakEntity<DevContainerModal>,
|
|
candidate_templates: Vec<TemplateEntry>,
|
|
matching_indices: Vec<usize>,
|
|
on_confirm: Box<
|
|
dyn FnMut(
|
|
TemplateEntry,
|
|
&mut DevContainerModal,
|
|
&mut Window,
|
|
&mut Context<DevContainerModal>,
|
|
),
|
|
>,
|
|
}
|
|
|
|
impl TemplatePickerDelegate {
|
|
fn new(
|
|
placeholder_text: String,
|
|
stateful_modal: WeakEntity<DevContainerModal>,
|
|
elements: Vec<TemplateEntry>,
|
|
on_confirm: Box<
|
|
dyn FnMut(
|
|
TemplateEntry,
|
|
&mut DevContainerModal,
|
|
&mut Window,
|
|
&mut Context<DevContainerModal>,
|
|
),
|
|
>,
|
|
) -> 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<picker::Picker<Self>>,
|
|
) {
|
|
self.selected_index = ix;
|
|
}
|
|
|
|
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
|
|
self.placeholder_text.clone().into()
|
|
}
|
|
|
|
fn update_matches(
|
|
&mut self,
|
|
query: String,
|
|
_window: &mut Window,
|
|
_cx: &mut Context<picker::Picker<Self>>,
|
|
) -> 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<picker::Picker<Self>>,
|
|
) {
|
|
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<picker::Picker<Self>>) {
|
|
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<picker::Picker<Self>>,
|
|
) -> Option<Self::ListItem> {
|
|
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<Picker<Self>>,
|
|
) -> Option<AnyElement> {
|
|
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<DevContainerModal>,
|
|
candidate_features: Vec<FeatureEntry>,
|
|
template_entry: TemplateEntry,
|
|
matching_indices: Vec<usize>,
|
|
on_confirm: Box<
|
|
dyn FnMut(
|
|
TemplateEntry,
|
|
&mut DevContainerModal,
|
|
&mut Window,
|
|
&mut Context<DevContainerModal>,
|
|
),
|
|
>,
|
|
}
|
|
|
|
impl FeaturePickerDelegate {
|
|
fn new(
|
|
placeholder_text: String,
|
|
stateful_modal: WeakEntity<DevContainerModal>,
|
|
candidate_features: Vec<FeatureEntry>,
|
|
template_entry: TemplateEntry,
|
|
on_confirm: Box<
|
|
dyn FnMut(
|
|
TemplateEntry,
|
|
&mut DevContainerModal,
|
|
&mut Window,
|
|
&mut Context<DevContainerModal>,
|
|
),
|
|
>,
|
|
) -> 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<Picker<Self>>,
|
|
) {
|
|
self.selected_index = ix;
|
|
}
|
|
|
|
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
|
|
self.placeholder_text.clone().into()
|
|
}
|
|
|
|
fn update_matches(
|
|
&mut self,
|
|
query: String,
|
|
_window: &mut Window,
|
|
_cx: &mut Context<Picker<Self>>,
|
|
) -> 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<Picker<Self>>) {
|
|
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<Picker<Self>>) {
|
|
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<Picker<Self>>,
|
|
) -> Option<Self::ListItem> {
|
|
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<Picker<Self>>,
|
|
) -> Option<AnyElement> {
|
|
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<Workspace>, _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<Self>) -> 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<Self>,
|
|
) -> 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<Self>,
|
|
) -> 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<Self>,
|
|
) -> 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<Self>,
|
|
) -> 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<Self>,
|
|
) -> 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<Self>) -> 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<Self>) -> 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<Self>,
|
|
) -> 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<Self>,
|
|
) {
|
|
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_ghcr_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::<Vec<TemplateEntry>>();
|
|
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::<Vec<(&String, &TemplateOptions)>>()
|
|
.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::<Vec<(&String, &TemplateOptions)>>()
|
|
.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_ghcr_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::<Vec<FeatureEntry>>();
|
|
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<DismissEvent> 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<Self>) -> impl IntoElement {
|
|
self.render_inner(window, cx)
|
|
}
|
|
}
|
|
|
|
trait StatefulModal: ModalView + EventEmitter<DismissEvent> + Render {
|
|
type State;
|
|
type Message;
|
|
|
|
fn state(&self) -> Self::State;
|
|
|
|
fn render_for_state(
|
|
&self,
|
|
state: Self::State,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> AnyElement;
|
|
|
|
fn accept_message(
|
|
&mut self,
|
|
message: Self::Message,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
);
|
|
|
|
fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
|
|
cx.emit(DismissEvent);
|
|
}
|
|
|
|
fn render_inner(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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)
|
|
}
|
|
}
|
|
|
|
fn ghcr_registry() -> &'static str {
|
|
"ghcr.io"
|
|
}
|
|
|
|
fn devcontainer_templates_repository() -> &'static str {
|
|
"devcontainers/templates"
|
|
}
|
|
|
|
fn devcontainer_features_repository() -> &'static str {
|
|
"devcontainers/features"
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Clone, PartialEq, Eq)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct TemplateOptions {
|
|
#[serde(rename = "type")]
|
|
option_type: String,
|
|
description: Option<String>,
|
|
proposals: Option<Vec<String>>,
|
|
#[serde(rename = "enum")]
|
|
enum_values: Option<Vec<String>>,
|
|
// 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<String, D::Error>
|
|
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<String> {
|
|
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, Clone, PartialEq, Eq, Hash)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct DevContainerFeature {
|
|
id: String,
|
|
version: String,
|
|
name: String,
|
|
source_repository: Option<String>,
|
|
}
|
|
|
|
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<HashMap<String, TemplateOptions>>,
|
|
source_repository: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct DevContainerFeaturesResponse {
|
|
features: Vec<DevContainerFeature>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct DevContainerTemplatesResponse {
|
|
templates: Vec<DevContainerTemplate>,
|
|
}
|
|
|
|
fn dispatch_apply_templates(
|
|
template_entry: TemplateEntry,
|
|
workspace: Entity<Workspace>,
|
|
window: &mut Window,
|
|
check_for_existing: bool,
|
|
cx: &mut Context<DevContainerModal>,
|
|
) {
|
|
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 environment = context.environment(cx).await;
|
|
|
|
{
|
|
if check_for_existing
|
|
&& read_default_devcontainer_configuration(&context, environment)
|
|
.await
|
|
.is_ok()
|
|
{
|
|
this.update_in(cx, |this, window, cx| {
|
|
this.accept_message(
|
|
DevContainerMessage::NeedConfirmWriteDevContainer(template_entry),
|
|
window,
|
|
cx,
|
|
);
|
|
})
|
|
.ok();
|
|
return;
|
|
}
|
|
|
|
let worktree = workspace.read_with(cx, |workspace, cx| {
|
|
workspace.project().read(cx).worktree_for_id(tree_id, cx)
|
|
});
|
|
|
|
let files = match apply_devcontainer_template(
|
|
worktree.unwrap(),
|
|
&template_entry.template,
|
|
&template_entry.options_selected,
|
|
&template_entry.features_selected,
|
|
&context,
|
|
cx,
|
|
)
|
|
.await
|
|
{
|
|
Ok(files) => files,
|
|
Err(e) => {
|
|
this.update_in(cx, |this, window, cx| {
|
|
this.accept_message(
|
|
DevContainerMessage::FailedToWriteTemplate(
|
|
DevContainerError::DevContainerTemplateApplyFailed(e.to_string()),
|
|
),
|
|
window,
|
|
cx,
|
|
);
|
|
})
|
|
.ok();
|
|
return;
|
|
}
|
|
};
|
|
|
|
if files.project_files.contains(&Arc::from(
|
|
RelPath::unix(".devcontainer/devcontainer.json").unwrap(),
|
|
)) {
|
|
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_ghcr_templates(
|
|
client: Arc<dyn HttpClient>,
|
|
) -> Result<DevContainerTemplatesResponse, String> {
|
|
let token = get_oci_token(
|
|
ghcr_registry(),
|
|
devcontainer_templates_repository(),
|
|
&client,
|
|
)
|
|
.await?;
|
|
let manifest = get_latest_oci_manifest(
|
|
&token.token,
|
|
ghcr_registry(),
|
|
devcontainer_templates_repository(),
|
|
&client,
|
|
None,
|
|
)
|
|
.await?;
|
|
|
|
let mut template_response: DevContainerTemplatesResponse = get_deserializable_oci_blob(
|
|
&token.token,
|
|
ghcr_registry(),
|
|
devcontainer_templates_repository(),
|
|
&manifest.layers[0].digest,
|
|
&client,
|
|
)
|
|
.await?;
|
|
|
|
for template in &mut template_response.templates {
|
|
template.source_repository = Some(format!(
|
|
"{}/{}",
|
|
ghcr_registry(),
|
|
devcontainer_templates_repository()
|
|
));
|
|
}
|
|
Ok(template_response)
|
|
}
|
|
|
|
async fn get_ghcr_features(
|
|
client: Arc<dyn HttpClient>,
|
|
) -> Result<DevContainerFeaturesResponse, String> {
|
|
let token = get_oci_token(
|
|
ghcr_registry(),
|
|
devcontainer_templates_repository(),
|
|
&client,
|
|
)
|
|
.await?;
|
|
|
|
let manifest = get_latest_oci_manifest(
|
|
&token.token,
|
|
ghcr_registry(),
|
|
devcontainer_features_repository(),
|
|
&client,
|
|
None,
|
|
)
|
|
.await?;
|
|
|
|
let mut features_response: DevContainerFeaturesResponse = get_deserializable_oci_blob(
|
|
&token.token,
|
|
ghcr_registry(),
|
|
devcontainer_features_repository(),
|
|
&manifest.layers[0].digest,
|
|
&client,
|
|
)
|
|
.await?;
|
|
|
|
for feature in &mut features_response.features {
|
|
feature.source_repository = Some(format!(
|
|
"{}/{}",
|
|
ghcr_registry(),
|
|
devcontainer_features_repository()
|
|
));
|
|
}
|
|
Ok(features_response)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use http_client::{FakeHttpClient, anyhow};
|
|
|
|
use crate::{
|
|
DevContainerTemplatesResponse, devcontainer_templates_repository,
|
|
get_deserializable_oci_blob, ghcr_registry,
|
|
};
|
|
|
|
#[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: Result<DevContainerTemplatesResponse, String> = get_deserializable_oci_blob(
|
|
"",
|
|
ghcr_registry(),
|
|
devcontainer_templates_repository(),
|
|
"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");
|
|
}
|
|
}
|