diff --git a/Cargo.lock b/Cargo.lock index 56531e8c40df2ef1dcb9c22929f9267e94689379..d7875fabd09d7ef7509f28181c2e2f30b734d223 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4881,6 +4881,28 @@ version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" +[[package]] +name = "dev_container" +version = "0.1.0" +dependencies = [ + "futures 0.3.31", + "gpui", + "http 1.3.1", + "http_client", + "log", + "menu", + "node_runtime", + "paths", + "picker", + "serde", + "serde_json", + "settings", + "smol", + "ui", + "util", + "workspace", +] + [[package]] name = "diagnostics" version = "0.1.0" @@ -13320,6 +13342,7 @@ dependencies = [ "auto_update", "dap", "db", + "dev_container", "editor", "extension", "extension_host", @@ -20796,6 +20819,7 @@ dependencies = [ "debug_adapter_extension", "debugger_tools", "debugger_ui", + "dev_container", "diagnostics", "edit_prediction", "edit_prediction_ui", diff --git a/Cargo.toml b/Cargo.toml index 8ad47bb4cc1a51dc35e1c0d83c8516c5375e5ab6..22de122d412317c9ac79dadc40a04cec3c7deff8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,7 @@ members = [ "crates/debugger_ui", "crates/deepseek", "crates/denoise", + "crates/dev_container", "crates/diagnostics", "crates/docs_preprocessor", "crates/edit_prediction", @@ -295,6 +296,7 @@ debugger_tools = { path = "crates/debugger_tools" } debugger_ui = { path = "crates/debugger_ui" } deepseek = { path = "crates/deepseek" } derive_refineable = { path = "crates/refineable/derive_refineable" } +dev_container = { path = "crates/dev_container" } diagnostics = { path = "crates/diagnostics" } editor = { path = "crates/editor" } encoding_selector = { path = "crates/encoding_selector" } diff --git a/crates/dev_container/Cargo.toml b/crates/dev_container/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..31f0466d45e84569b3e2609742d5ba2d1ac59568 --- /dev/null +++ b/crates/dev_container/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "dev_container" +version = "0.1.0" +publish.workspace = true +edition.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true +http_client.workspace = true +http.workspace = true +gpui.workspace = true +futures.workspace = true +log.workspace = true +node_runtime.workspace = true +menu.workspace = true +paths.workspace = true +picker.workspace = true +settings.workspace = true +smol.workspace = true +ui.workspace = true +util.workspace = true +workspace.workspace = true + +[dev-dependencies] +gpui = { workspace = true, features = ["test-support"] } + +[lints] +workspace = true diff --git a/crates/dev_container/LICENSE-GPL b/crates/dev_container/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/dev_container/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/recent_projects/src/dev_container.rs b/crates/dev_container/src/devcontainer_api.rs similarity index 54% rename from crates/recent_projects/src/dev_container.rs rename to crates/dev_container/src/devcontainer_api.rs index 0692542f742d76bcc3a3fa2920a8615900fcb60f..c1dd5982d3858e3263b8877716fc9461e4b14b93 100644 --- a/crates/recent_projects/src/dev_container.rs +++ b/crates/dev_container/src/devcontainer_api.rs @@ -1,15 +1,18 @@ -use std::fmt::Display; -use std::path::{Path, PathBuf}; -use std::sync::Arc; +use std::{ + collections::{HashMap, HashSet}, + fmt::Display, + path::{Path, PathBuf}, + sync::Arc, +}; use gpui::AsyncWindowContext; use node_runtime::NodeRuntime; use serde::Deserialize; -use settings::DevContainerConnection; -use smol::fs; +use settings::{DevContainerConnection, Settings as _}; +use smol::{fs, process::Command}; use workspace::Workspace; -use crate::remote_connections::Connection; +use crate::{DevContainerFeature, DevContainerSettings, DevContainerTemplate}; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -22,15 +25,172 @@ struct DevContainerUp { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -struct DevContainerConfiguration { +pub(crate) struct DevContainerApply { + pub(crate) files: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DevContainerConfiguration { name: Option, } #[derive(Debug, Deserialize)] -struct DevContainerConfigurationOutput { +pub(crate) struct DevContainerConfigurationOutput { configuration: DevContainerConfiguration, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DevContainerError { + DockerNotAvailable, + DevContainerCliNotAvailable, + DevContainerTemplateApplyFailed(String), + DevContainerUpFailed(String), + DevContainerNotFound, + DevContainerParseFailed, + NodeRuntimeNotAvailable, + NotInValidProject, +} + +impl Display for DevContainerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + DevContainerError::DockerNotAvailable => + "Docker CLI not found on $PATH".to_string(), + DevContainerError::DevContainerCliNotAvailable => + "Docker not found on path".to_string(), + DevContainerError::DevContainerUpFailed(message) => { + format!("DevContainer creation failed with error: {}", message) + } + DevContainerError::DevContainerTemplateApplyFailed(message) => { + format!("DevContainer template apply failed with error: {}", message) + } + DevContainerError::DevContainerNotFound => + "No valid dev container definition found in project".to_string(), + DevContainerError::DevContainerParseFailed => + "Failed to parse file .devcontainer/devcontainer.json".to_string(), + DevContainerError::NodeRuntimeNotAvailable => + "Cannot find a valid node runtime".to_string(), + DevContainerError::NotInValidProject => "Not within a valid project".to_string(), + } + ) + } +} + +pub(crate) async fn read_devcontainer_configuration_for_project( + cx: &mut AsyncWindowContext, + node_runtime: &NodeRuntime, +) -> Result { + let (path_to_devcontainer_cli, found_in_path) = ensure_devcontainer_cli(&node_runtime).await?; + + let Some(directory) = project_directory(cx) else { + return Err(DevContainerError::NotInValidProject); + }; + + devcontainer_read_configuration( + &path_to_devcontainer_cli, + found_in_path, + node_runtime, + &directory, + use_podman(cx), + ) + .await +} + +pub(crate) async fn apply_dev_container_template( + template: &DevContainerTemplate, + options_selected: &HashMap, + features_selected: &HashSet, + cx: &mut AsyncWindowContext, + node_runtime: &NodeRuntime, +) -> Result { + let (path_to_devcontainer_cli, found_in_path) = ensure_devcontainer_cli(&node_runtime).await?; + + let Some(directory) = project_directory(cx) else { + return Err(DevContainerError::NotInValidProject); + }; + + devcontainer_template_apply( + template, + options_selected, + features_selected, + &path_to_devcontainer_cli, + found_in_path, + node_runtime, + &directory, + false, // devcontainer template apply does not use --docker-path option + ) + .await +} + +fn use_podman(cx: &mut AsyncWindowContext) -> bool { + cx.update(|_, cx| DevContainerSettings::get_global(cx).use_podman) + .unwrap_or(false) +} + +pub async fn start_dev_container( + cx: &mut AsyncWindowContext, + node_runtime: NodeRuntime, +) -> Result<(DevContainerConnection, String), DevContainerError> { + let use_podman = use_podman(cx); + check_for_docker(use_podman).await?; + + let (path_to_devcontainer_cli, found_in_path) = ensure_devcontainer_cli(&node_runtime).await?; + + let Some(directory) = project_directory(cx) else { + return Err(DevContainerError::NotInValidProject); + }; + + match devcontainer_up( + &path_to_devcontainer_cli, + found_in_path, + &node_runtime, + directory.clone(), + use_podman, + ) + .await + { + Ok(DevContainerUp { + container_id, + remote_workspace_folder, + .. + }) => { + let project_name = match devcontainer_read_configuration( + &path_to_devcontainer_cli, + found_in_path, + &node_runtime, + &directory, + use_podman, + ) + .await + { + Ok(DevContainerConfigurationOutput { + configuration: + DevContainerConfiguration { + name: Some(project_name), + }, + }) => project_name, + _ => get_backup_project_name(&remote_workspace_folder, &container_id), + }; + + let connection = DevContainerConnection { + name: project_name, + container_id: container_id, + use_podman, + }; + + Ok((connection, remote_workspace_folder)) + } + Err(err) => { + let message = format!("Failed with nested error: {}", err); + Err(DevContainerError::DevContainerUpFailed(message)) + } + } +} + #[cfg(not(target_os = "windows"))] fn dev_container_cli() -> String { "devcontainer".to_string() @@ -41,8 +201,12 @@ fn dev_container_cli() -> String { "devcontainer.cmd".to_string() } -async fn check_for_docker() -> Result<(), DevContainerError> { - let mut command = util::command::new_smol_command("docker"); +async fn check_for_docker(use_podman: bool) -> Result<(), DevContainerError> { + let mut command = if use_podman { + util::command::new_smol_command("podman") + } else { + util::command::new_smol_command("docker") + }; command.arg("--version"); match command.output().await { @@ -147,29 +311,20 @@ async fn devcontainer_up( found_in_path: bool, node_runtime: &NodeRuntime, path: Arc, + use_podman: bool, ) -> Result { let Ok(node_runtime_path) = node_runtime.binary_path().await else { log::error!("Unable to find node runtime path"); return Err(DevContainerError::NodeRuntimeNotAvailable); }; - let mut command = if found_in_path { - let mut command = util::command::new_smol_command(path_to_cli.display().to_string()); - command.arg("up"); - command.arg("--workspace-folder"); - command.arg(path.display().to_string()); - command - } else { - let mut command = - util::command::new_smol_command(node_runtime_path.as_os_str().display().to_string()); - command.arg(path_to_cli.display().to_string()); - command.arg("up"); - command.arg("--workspace-folder"); - command.arg(path.display().to_string()); - command - }; + let mut command = + devcontainer_cli_command(path_to_cli, found_in_path, &node_runtime_path, use_podman); + command.arg("up"); + command.arg("--workspace-folder"); + command.arg(path.display().to_string()); - log::debug!("Running full devcontainer up command: {:?}", command); + log::info!("Running full devcontainer up command: {:?}", command); match command.output().await { Ok(output) => { @@ -200,15 +355,24 @@ async fn devcontainer_up( } } } - async fn devcontainer_read_configuration( path_to_cli: &PathBuf, - path: Arc, + found_in_path: bool, + node_runtime: &NodeRuntime, + path: &Arc, + use_podman: bool, ) -> Result { - let mut command = util::command::new_smol_command(path_to_cli.display().to_string()); + let Ok(node_runtime_path) = node_runtime.binary_path().await else { + log::error!("Unable to find node runtime path"); + return Err(DevContainerError::NodeRuntimeNotAvailable); + }; + + let mut command = + devcontainer_cli_command(path_to_cli, found_in_path, &node_runtime_path, use_podman); command.arg("read-configuration"); command.arg("--workspace-folder"); command.arg(path.display().to_string()); + match command.output().await { Ok(output) => { if output.status.success() { @@ -227,39 +391,118 @@ async fn devcontainer_read_configuration( String::from_utf8_lossy(&output.stderr) ); log::error!("{}", &message); - Err(DevContainerError::DevContainerUpFailed(message)) + Err(DevContainerError::DevContainerNotFound) } } Err(e) => { let message = format!("Error running devcontainer read-configuration: {:?}", e); log::error!("{}", &message); - Err(DevContainerError::DevContainerUpFailed(message)) + Err(DevContainerError::DevContainerNotFound) } } } -// Name the project with two fallbacks -async fn get_project_name( +async fn devcontainer_template_apply( + template: &DevContainerTemplate, + template_options: &HashMap, + features_selected: &HashSet, path_to_cli: &PathBuf, - path: Arc, - remote_workspace_folder: String, - container_id: String, -) -> Result { - if let Ok(dev_container_configuration) = - devcontainer_read_configuration(path_to_cli, path).await - && let Some(name) = dev_container_configuration.configuration.name - { - // Ideally, name the project after the name defined in devcontainer.json - Ok(name) + found_in_path: bool, + node_runtime: &NodeRuntime, + path: &Arc, + use_podman: bool, +) -> Result { + let Ok(node_runtime_path) = node_runtime.binary_path().await else { + log::error!("Unable to find node runtime path"); + return Err(DevContainerError::NodeRuntimeNotAvailable); + }; + + let mut command = + devcontainer_cli_command(path_to_cli, found_in_path, &node_runtime_path, use_podman); + + let Ok(serialized_options) = serde_json::to_string(template_options) else { + log::error!("Unable to serialize options for {:?}", template_options); + return Err(DevContainerError::DevContainerParseFailed); + }; + + command.arg("templates"); + command.arg("apply"); + command.arg("--workspace-folder"); + command.arg(path.display().to_string()); + command.arg("--template-id"); + command.arg(format!( + "{}/{}", + template + .source_repository + .as_ref() + .unwrap_or(&String::from("")), + template.id + )); + command.arg("--template-args"); + command.arg(serialized_options); + command.arg("--features"); + command.arg(template_features_to_json(features_selected)); + + log::debug!("Running full devcontainer apply command: {:?}", command); + + match command.output().await { + Ok(output) => { + if output.status.success() { + let raw = String::from_utf8_lossy(&output.stdout); + serde_json::from_str::(&raw).map_err(|e| { + log::error!( + "Unable to parse response from 'devcontainer templates apply' command, error: {:?}", + e + ); + DevContainerError::DevContainerParseFailed + }) + } else { + let message = format!( + "Non-success status running devcontainer templates apply for workspace: out: {:?}, err: {:?}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + log::error!("{}", &message); + Err(DevContainerError::DevContainerTemplateApplyFailed(message)) + } + } + Err(e) => { + let message = format!("Error running devcontainer templates apply: {:?}", e); + log::error!("{}", &message); + Err(DevContainerError::DevContainerTemplateApplyFailed(message)) + } + } +} + +fn devcontainer_cli_command( + path_to_cli: &PathBuf, + found_in_path: bool, + node_runtime_path: &PathBuf, + use_podman: bool, +) -> Command { + let mut command = if found_in_path { + util::command::new_smol_command(path_to_cli.display().to_string()) } else { - // Otherwise, name the project after the remote workspace folder name - Ok(Path::new(&remote_workspace_folder) - .file_name() - .and_then(|name| name.to_str()) - .map(|string| string.into()) - // Finally, name the project after the container ID as a last resort - .unwrap_or_else(|| container_id.clone())) + let mut command = + util::command::new_smol_command(node_runtime_path.as_os_str().display().to_string()); + command.arg(path_to_cli.display().to_string()); + command + }; + + if use_podman { + command.arg("--docker-path"); + command.arg("podman"); } + command +} + +fn get_backup_project_name(remote_workspace_folder: &str, container_id: &str) -> String { + Path::new(remote_workspace_folder) + .file_name() + .and_then(|name| name.to_str()) + .map(|string| string.to_string()) + .unwrap_or_else(|| container_id.to_string()) } fn project_directory(cx: &mut AsyncWindowContext) -> Option> { @@ -278,90 +521,32 @@ fn project_directory(cx: &mut AsyncWindowContext) -> Option> { } } -pub(crate) async fn start_dev_container( - cx: &mut AsyncWindowContext, - node_runtime: NodeRuntime, -) -> Result<(Connection, String), DevContainerError> { - check_for_docker().await?; - - let (path_to_devcontainer_cli, found_in_path) = ensure_devcontainer_cli(&node_runtime).await?; - - let Some(directory) = project_directory(cx) else { - return Err(DevContainerError::DevContainerNotFound); - }; - - match devcontainer_up( - &path_to_devcontainer_cli, - found_in_path, - &node_runtime, - directory.clone(), - ) - .await - { - Ok(DevContainerUp { - container_id, - remote_workspace_folder, - .. - }) => { - let project_name = get_project_name( - &path_to_devcontainer_cli, - directory, - remote_workspace_folder.clone(), - container_id.clone(), - ) - .await?; - - let connection = Connection::DevContainer(DevContainerConnection { - name: project_name, - container_id, - }); - - Ok((connection, remote_workspace_folder)) - } - Err(err) => { - let message = format!("Failed with nested error: {}", err); - Err(DevContainerError::DevContainerUpFailed(message)) - } - } -} - -#[derive(Debug)] -pub(crate) enum DevContainerError { - DockerNotAvailable, - DevContainerCliNotAvailable, - DevContainerUpFailed(String), - DevContainerNotFound, - DevContainerParseFailed, - NodeRuntimeNotAvailable, -} - -impl Display for DevContainerError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "{}", - match self { - DevContainerError::DockerNotAvailable => - "Docker CLI not found on $PATH".to_string(), - DevContainerError::DevContainerCliNotAvailable => - "Docker not found on path".to_string(), - DevContainerError::DevContainerUpFailed(message) => { - format!("DevContainer creation failed with error: {}", message) - } - DevContainerError::DevContainerNotFound => "TODO what".to_string(), - DevContainerError::DevContainerParseFailed => - "Failed to parse file .devcontainer/devcontainer.json".to_string(), - DevContainerError::NodeRuntimeNotAvailable => - "Cannot find a valid node runtime".to_string(), - } - ) - } +fn template_features_to_json(features_selected: &HashSet) -> String { + let things = features_selected + .iter() + .map(|feature| { + let mut map = HashMap::new(); + map.insert( + "id", + format!( + "{}/{}:{}", + feature + .source_repository + .as_ref() + .unwrap_or(&String::from("")), + feature.id, + feature.major_version() + ), + ); + map + }) + .collect::>>(); + serde_json::to_string(&things).unwrap() } #[cfg(test)] -mod test { - - use crate::dev_container::DevContainerUp; +mod tests { + use crate::devcontainer_api::DevContainerUp; #[test] fn should_parse_from_devcontainer_json() { diff --git a/crates/dev_container/src/lib.rs b/crates/dev_container/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..d6ef63781632c36839c81fe47683bb4269e34e7a --- /dev/null +++ b/crates/dev_container/src/lib.rs @@ -0,0 +1,1812 @@ +use gpui::AppContext; +use gpui::Entity; +use gpui::Task; +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::read_devcontainer_configuration_for_project; + +use crate::devcontainer_api::DevContainerError; +use crate::devcontainer_api::apply_dev_container_template; + +pub use devcontainer_api::start_dev_container; + +#[derive(RegisterSetting)] +struct DevContainerSettings { + use_podman: bool, +} + +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; + + self.stateful_modal + .update(cx, |modal, cx| { + fun( + self.candidate_templates[self.matching_indices[self.selected_index]].clone(), + modal, + window, + cx, + ); + }) + .log_err(); + } + + fn dismissed(&mut self, window: &mut Window, cx: &mut Context>) { + self.stateful_modal + .update(cx, |modal, cx| { + modal.dismiss(&menu::Cancel, window, cx); + }) + .log_err(); + } + + 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) + }) + .log_err(); + } else { + let current = &mut self.candidate_features[self.matching_indices[self.selected_index]]; + 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); + }) + .log_err(); + } + + 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 client = cx.update(|_, cx| cx.http_client()).unwrap(); + 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); + }) + .log_err(); + } + Err(e) => { + let message = DevContainerMessage::ErrorRetrievingTemplates(e); + this.update_in(cx, |this, window, cx| { + this.accept_message(message, window, cx); + }) + .log_err(); + } + } + }) + .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 client = cx.update(|_, cx| cx.http_client()).unwrap(); + 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); + }) + .log_err(); + }) + .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| { + if let Some(tree_id) = workspace.update(cx, |workspace, cx| { + let project = workspace.project().clone(); + let worktree = project.read(cx).visible_worktrees(cx).find_map(|tree| { + tree.read(cx) + .root_entry()? + .is_dir() + .then_some(tree.read(cx)) + }); + worktree.map(|w| w.id()) + }) { + let node_runtime = workspace.read_with(cx, |workspace, _| { + workspace.app_state().node_runtime.clone() + }); + + if check_for_existing + && read_devcontainer_configuration_for_project(cx, &node_runtime) + .await + .is_ok() + { + this.update_in(cx, |this, window, cx| { + this.accept_message( + DevContainerMessage::NeedConfirmWriteDevContainer(template_entry), + window, + cx, + ); + }) + .log_err(); + return; + } + + let files = match apply_dev_container_template( + &template_entry.template, + &template_entry.options_selected, + &template_entry.features_selected, + cx, + &node_runtime, + ) + .await + { + Ok(files) => files, + Err(e) => { + this.update_in(cx, |this, window, cx| { + this.accept_message( + DevContainerMessage::FailedToWriteTemplate(e), + window, + cx, + ); + }) + .log_err(); + return; + } + }; + + if files + .files + .contains(&"./.devcontainer/devcontainer.json".to_string()) + { + let Some(workspace_task) = workspace + .update_in(cx, |workspace, window, cx| { + let path = RelPath::unix(".devcontainer/devcontainer.json").unwrap(); + workspace.open_path((tree_id, path), None, true, window, cx) + }) + .log_err() + else { + return; + }; + + workspace_task.await.log_err(); + } + this.update_in(cx, |this, window, cx| { + this.dismiss(&menu::Cancel, window, cx); + }) + .unwrap(); + } else { + return; + } + }) + .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 = Request::get(url) + .header("Authorization", format!("Bearer {}", token)) + .header("Accept", "application/vnd.oci.image.manifest.v1+json") + .body(AsyncBody::default()) + .unwrap(); + 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"); + } +} diff --git a/crates/recent_projects/Cargo.toml b/crates/recent_projects/Cargo.toml index 777b3c42de252b707ca6e28af007f9ceb206fb38..9bd6806077638218fae9edb4050e066a64c4a3d4 100644 --- a/crates/recent_projects/Cargo.toml +++ b/crates/recent_projects/Cargo.toml @@ -21,6 +21,7 @@ anyhow.workspace = true askpass.workspace = true auto_update.workspace = true db.workspace = true +dev_container.workspace = true editor.workspace = true extension_host.workspace = true file_finder.workspace = true diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index efd9688054ede3a63f536036b59a303818dacfe6..e365eb08ed04b4afb5d64532e87c44823474955c 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -1,4 +1,3 @@ -mod dev_container; mod dev_container_suggest; pub mod disconnected_overlay; mod remote_connections; @@ -10,6 +9,7 @@ use std::path::PathBuf; #[cfg(target_os = "windows")] mod wsl_picker; +use dev_container::start_dev_container; use remote::RemoteConnectionOptions; pub use remote_connections::{RemoteConnectionModal, connect, open_remote_project}; @@ -37,6 +37,8 @@ use workspace::{ }; use zed_actions::{OpenDevContainer, OpenRecent, OpenRemote}; +use crate::remote_connections::Connection; + #[derive(Clone, Debug)] pub struct RecentProjectEntry { pub name: SharedString, @@ -236,26 +238,22 @@ pub fn init(cx: &mut App) { let replace_window = window.window_handle().downcast::(); cx.spawn_in(window, async move |_, mut cx| { - let (connection, starting_dir) = match dev_container::start_dev_container( - &mut cx, - app_state.node_runtime.clone(), - ) - .await - { - Ok((c, s)) => (c, s), - Err(e) => { - log::error!("Failed to start Dev Container: {:?}", e); - cx.prompt( - gpui::PromptLevel::Critical, - "Failed to start Dev Container", - Some(&format!("{:?}", e)), - &["Ok"], - ) - .await - .ok(); - return; - } - }; + let (connection, starting_dir) = + match start_dev_container(&mut cx, app_state.node_runtime.clone()).await { + Ok((c, s)) => (Connection::DevContainer(c), s), + Err(e) => { + log::error!("Failed to start Dev Container: {:?}", e); + cx.prompt( + gpui::PromptLevel::Critical, + "Failed to start Dev Container", + Some(&format!("{:?}", e)), + &["Ok"], + ) + .await + .ok(); + return; + } + }; let result = open_remote_project( connection.into(), diff --git a/crates/recent_projects/src/remote_connections.rs b/crates/recent_projects/src/remote_connections.rs index 205119bb39921fe7812c638e3ce63cec08ba8eda..7e1ddbd2edb3f309ea9eeb3ddd3379245ab991ca 100644 --- a/crates/recent_projects/src/remote_connections.rs +++ b/crates/recent_projects/src/remote_connections.rs @@ -99,6 +99,7 @@ impl From for RemoteConnectionOptions { name: conn.name, container_id: conn.container_id, upload_binary_over_docker_exec: false, + use_podman: conn.use_podman, }) } } diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index 0c254d96447a7d22b4e84fe92bed9574e6840ba9..8a0e0189e356b89daea8317f3cd28b1ddcdbbe5a 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -1,11 +1,11 @@ use crate::{ - dev_container::start_dev_container, remote_connections::{ Connection, RemoteConnectionModal, RemoteConnectionPrompt, RemoteSettings, SshConnection, SshConnectionHeader, connect, determine_paths_with_positions, open_remote_project, }, ssh_config::parse_ssh_config_hosts, }; +use dev_container::start_dev_container; use editor::Editor; use file_finder::OpenPathDelegate; use futures::{FutureExt, channel::oneshot, future::Shared, select}; @@ -1602,7 +1602,7 @@ impl RemoteServerProjects { cx.spawn_in(window, async move |entity, cx| { let (connection, starting_dir) = match start_dev_container(cx, app_state.node_runtime.clone()).await { - Ok((c, s)) => (c, s), + Ok((c, s)) => (Connection::DevContainer(c), s), Err(e) => { log::error!("Failed to start dev container: {:?}", e); entity diff --git a/crates/remote/src/remote_client.rs b/crates/remote/src/remote_client.rs index 615b2b1029416294a282522117971cf7f009e701..ecfd46fcd4660e328b2c2d6ba21f16348e9e4e21 100644 --- a/crates/remote/src/remote_client.rs +++ b/crates/remote/src/remote_client.rs @@ -1260,7 +1260,13 @@ impl RemoteConnectionOptions { match self { RemoteConnectionOptions::Ssh(opts) => opts.host.to_string(), RemoteConnectionOptions::Wsl(opts) => opts.distro_name.clone(), - RemoteConnectionOptions::Docker(opts) => opts.name.clone(), + RemoteConnectionOptions::Docker(opts) => { + if opts.use_podman { + format!("[podman] {}", opts.name) + } else { + opts.name.clone() + } + } #[cfg(any(test, feature = "test-support"))] RemoteConnectionOptions::Mock(opts) => format!("mock-{}", opts.id), } diff --git a/crates/remote/src/transport/docker.rs b/crates/remote/src/transport/docker.rs index 07cb9876f3468252b899993c7fafec1b5f1bb719..1610c264e9ffdb337c31920d49be02abfe4bcd6a 100644 --- a/crates/remote/src/transport/docker.rs +++ b/crates/remote/src/transport/docker.rs @@ -34,6 +34,7 @@ pub struct DockerConnectionOptions { pub name: String, pub container_id: String, pub upload_binary_over_docker_exec: bool, + pub use_podman: bool, } pub(crate) struct DockerExecConnection { @@ -98,6 +99,14 @@ impl DockerExecConnection { Ok(this) } + fn docker_cli(&self) -> &str { + if self.connection_options.use_podman { + "podman" + } else { + "docker" + } + } + async fn discover_shell(&self) -> String { let default_shell = "sh"; match self @@ -369,7 +378,7 @@ impl DockerExecConnection { let src_path_display = src_path.display().to_string(); let dest_path_str = dest_path.display(self.path_style()); - let mut command = util::command::new_smol_command("docker"); + let mut command = util::command::new_smol_command(self.docker_cli()); command.arg("cp"); command.arg("-a"); command.arg(&src_path_display); @@ -401,7 +410,7 @@ impl DockerExecConnection { subcommand: &str, args: &[impl AsRef], ) -> Result { - let mut command = util::command::new_smol_command("docker"); + let mut command = util::command::new_smol_command(self.docker_cli()); command.arg(subcommand); for arg in args { command.arg(arg.as_ref()); @@ -585,7 +594,7 @@ impl RemoteConnection for DockerExecConnection { if reconnect { docker_args.push("--reconnect".to_string()); } - let mut command = util::command::new_smol_command("docker"); + let mut command = util::command::new_smol_command(self.docker_cli()); command .kill_on_drop(true) .stdin(Stdio::piped()) @@ -620,7 +629,7 @@ impl RemoteConnection for DockerExecConnection { let dest_path_str = dest_path.to_string(); let src_path_display = src_path.display().to_string(); - let mut command = util::command::new_smol_command("docker"); + let mut command = util::command::new_smol_command(self.docker_cli()); command.arg("cp"); command.arg("-a"); // Archive mode is required to assign the file ownership to the default docker exec user command.arg(src_path_display); @@ -706,7 +715,7 @@ impl RemoteConnection for DockerExecConnection { docker_args.append(&mut inner_program); Ok(CommandTemplate { - program: "docker".to_string(), + program: self.docker_cli().to_string(), args: docker_args, // Docker-exec pipes in environment via the "-e" argument env: Default::default(), diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index e9e839c44e53836195f6d8a315fb65bd2f4f29b1..6923ba088b29afeaf3bc6ddbf6ca137c10f97166 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -976,6 +976,7 @@ pub struct RemoteSettingsContent { pub wsl_connections: Option>, pub dev_container_connections: Option>, pub read_ssh_config: Option, + pub use_podman: Option, } #[with_fallible_options] @@ -985,6 +986,7 @@ pub struct RemoteSettingsContent { pub struct DevContainerConnection { pub name: String, pub container_id: String, + pub use_podman: bool, } #[with_fallible_options] diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index e7352816a905e18a6dfc15512797b1ba45fd0109..adb6d7a95cb62dc211fbbd68eb3a02de644f6b30 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -881,6 +881,9 @@ impl Domain for WorkspaceDb { DROP TABLE user_toolchains; ALTER TABLE user_toolchains2 RENAME TO user_toolchains; ), + sql!( + ALTER TABLE remote_connections ADD COLUMN use_podman BOOLEAN; + ), ]; // Allow recovering from bad migration that was initially shipped to nightly @@ -1308,6 +1311,7 @@ impl WorkspaceDb { let mut distro = None; let mut name = None; let mut container_id = None; + let mut use_podman = None; match options { RemoteConnectionOptions::Ssh(options) => { kind = RemoteConnectionKind::Ssh; @@ -1324,6 +1328,7 @@ impl WorkspaceDb { kind = RemoteConnectionKind::Docker; container_id = Some(options.container_id); name = Some(options.name); + use_podman = Some(options.use_podman) } #[cfg(any(test, feature = "test-support"))] RemoteConnectionOptions::Mock(options) => { @@ -1340,6 +1345,7 @@ impl WorkspaceDb { distro, name, container_id, + use_podman, ) } @@ -1352,6 +1358,7 @@ impl WorkspaceDb { distro: Option, name: Option, container_id: Option, + use_podman: Option, ) -> Result { if let Some(id) = this.select_row_bound(sql!( SELECT id @@ -1384,8 +1391,9 @@ impl WorkspaceDb { user, distro, name, - container_id - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + container_id, + use_podman + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) RETURNING id ))?(( kind.serialize(), @@ -1395,6 +1403,7 @@ impl WorkspaceDb { distro, name, container_id, + use_podman, ))? .context("failed to insert remote project")?; Ok(RemoteConnectionId(id)) @@ -1478,25 +1487,28 @@ impl WorkspaceDb { fn remote_connections(&self) -> Result> { Ok(self.select(sql!( SELECT - id, kind, host, port, user, distro, container_id, name + id, kind, host, port, user, distro, container_id, name, use_podman FROM remote_connections ))?()? .into_iter() - .filter_map(|(id, kind, host, port, user, distro, container_id, name)| { - Some(( - RemoteConnectionId(id), - Self::remote_connection_from_row( - kind, - host, - port, - user, - distro, - container_id, - name, - )?, - )) - }) + .filter_map( + |(id, kind, host, port, user, distro, container_id, name, use_podman)| { + Some(( + RemoteConnectionId(id), + Self::remote_connection_from_row( + kind, + host, + port, + user, + distro, + container_id, + name, + use_podman, + )?, + )) + }, + ) .collect()) } @@ -1504,14 +1516,24 @@ impl WorkspaceDb { &self, id: RemoteConnectionId, ) -> Result { - let (kind, host, port, user, distro, container_id, name) = self.select_row_bound(sql!( - SELECT kind, host, port, user, distro, container_id, name - FROM remote_connections - WHERE id = ? - ))?(id.0)? - .context("no such remote connection")?; - Self::remote_connection_from_row(kind, host, port, user, distro, container_id, name) - .context("invalid remote_connection row") + let (kind, host, port, user, distro, container_id, name, use_podman) = + self.select_row_bound(sql!( + SELECT kind, host, port, user, distro, container_id, name, use_podman + FROM remote_connections + WHERE id = ? + ))?(id.0)? + .context("no such remote connection")?; + Self::remote_connection_from_row( + kind, + host, + port, + user, + distro, + container_id, + name, + use_podman, + ) + .context("invalid remote_connection row") } fn remote_connection_from_row( @@ -1522,6 +1544,7 @@ impl WorkspaceDb { distro: Option, container_id: Option, name: Option, + use_podman: Option, ) -> Option { match RemoteConnectionKind::deserialize(&kind)? { RemoteConnectionKind::Wsl => Some(RemoteConnectionOptions::Wsl(WslConnectionOptions { @@ -1539,6 +1562,7 @@ impl WorkspaceDb { container_id: container_id?, name: name?, upload_binary_over_docker_exec: false, + use_podman: use_podman?, })) } } diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 153d7983243adaf89e8d243b873c53bbbfb164da..6bb43be96e17a3163adca7e78edebae994531550 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -96,6 +96,7 @@ db.workspace = true debug_adapter_extension.workspace = true debugger_tools.workspace = true debugger_ui.workspace = true +dev_container.workspace = true diagnostics.workspace = true editor.workspace = true encoding_selector.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index e6b1907ce34f7ca161d0c136f53726a034fede27..7309ba01b86f094590ef9e8608d55622d5b50171 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -628,6 +628,7 @@ fn main() { agent_ui_v2::agents_panel::init(cx); repl::init(app_state.fs.clone(), cx); recent_projects::init(cx); + dev_container::init(cx); load_embedded_fonts(cx);