Devcontainer setup modal (#47021)

KyleBarton and Sam Coward created

Adds the ability to create a dev container definition from scratch.
Additionally, separates devcontainer logic out into its own crate, since
it was getting sufficiently complex to separate from the
`recent_projects` crate.

A screen recording of the modal experience:


https://github.com/user-attachments/assets/f6cf95e1-eb7b-4ca3-86c7-c1cbc26ca557


Release Notes:

- Added modal to initialize a dev container definition in the project
with `projects: initialize dev container`
- Added podman support for dev container actions with the `use_podman`
setting
- Improved devcontainer error handling

---------

Co-authored-by: Sam Coward <idoru42@gmail.com>

Change summary

Cargo.lock                                       |   24 
Cargo.toml                                       |    2 
crates/dev_container/Cargo.toml                  |   29 
crates/dev_container/LICENSE-GPL                 |    1 
crates/dev_container/src/devcontainer_api.rs     |  447 +++-
crates/dev_container/src/lib.rs                  | 1812 ++++++++++++++++++
crates/recent_projects/Cargo.toml                |    1 
crates/recent_projects/src/recent_projects.rs    |   40 
crates/recent_projects/src/remote_connections.rs |    1 
crates/recent_projects/src/remote_servers.rs     |    4 
crates/remote/src/remote_client.rs               |    8 
crates/remote/src/transport/docker.rs            |   19 
crates/settings_content/src/settings_content.rs  |    2 
crates/workspace/src/persistence.rs              |   74 
crates/zed/Cargo.toml                            |    1 
crates/zed/src/main.rs                           |    1 
16 files changed, 2,281 insertions(+), 185 deletions(-)

Detailed changes

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",

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" }

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

crates/recent_projects/src/dev_container.rs → 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<String>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct DevContainerConfiguration {
     name: Option<String>,
 }
 
 #[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<DevContainerConfigurationOutput, DevContainerError> {
+    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<String, String>,
+    features_selected: &HashSet<DevContainerFeature>,
+    cx: &mut AsyncWindowContext,
+    node_runtime: &NodeRuntime,
+) -> Result<DevContainerApply, DevContainerError> {
+    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<Path>,
+    use_podman: bool,
 ) -> Result<DevContainerUp, DevContainerError> {
     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<Path>,
+    found_in_path: bool,
+    node_runtime: &NodeRuntime,
+    path: &Arc<Path>,
+    use_podman: bool,
 ) -> Result<DevContainerConfigurationOutput, DevContainerError> {
-    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<String, String>,
+    features_selected: &HashSet<DevContainerFeature>,
     path_to_cli: &PathBuf,
-    path: Arc<Path>,
-    remote_workspace_folder: String,
-    container_id: String,
-) -> Result<String, DevContainerError> {
-    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<Path>,
+    use_podman: bool,
+) -> Result<DevContainerApply, DevContainerError> {
+    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::<DevContainerApply>(&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<Arc<Path>> {
@@ -278,90 +521,32 @@ fn project_directory(cx: &mut AsyncWindowContext) -> Option<Arc<Path>> {
     }
 }
 
-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<DevContainerFeature>) -> 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::<Vec<HashMap<&str, String>>>();
+    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() {

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<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;
+
+        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<picker::Picker<Self>>) {
+        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<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)
+                })
+                .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(&current.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);
+            })
+            .log_err();
+    }
+
+    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 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::<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 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::<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)
+    }
+}
+
+#[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<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)]
+#[serde(rename_all = "camelCase")]
+struct DockerManifestsResponse {
+    layers: Vec<ManifestLayer>,
+}
+
+#[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| {
+        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<dyn HttpClient>,
+) -> Result<DevContainerTemplatesResponse, String> {
+    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<dyn HttpClient>) -> Result<DevContainerFeaturesResponse, String> {
+    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<dyn HttpClient>) -> Result<GithubTokenResponse, String> {
+    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<dyn HttpClient>,
+) -> Result<DockerManifestsResponse, String> {
+    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<dyn HttpClient>,
+) -> Result<DockerManifestsResponse, String> {
+    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<dyn HttpClient>,
+) -> Result<DevContainerFeaturesResponse, String> {
+    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<dyn HttpClient>,
+) -> Result<DevContainerTemplatesResponse, String> {
+    let url = format!(
+        "{}/v2/{}/blobs/{}",
+        ghcr_url(),
+        devcontainer_templates_repository(),
+        blob_digest
+    );
+    get_deserialized_response(token, &url, client).await
+}
+
+async fn get_deserialized_response<T>(
+    token: &str,
+    url: &str,
+    client: &Arc<dyn HttpClient>,
+) -> Result<T, String>
+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::<GithubTokenResponse>("", "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");
+    }
+}

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

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::<Workspace>();
 
             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(),

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

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),
         }

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<str>],
     ) -> Result<String> {
-        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(),

crates/settings_content/src/settings_content.rs 🔗

@@ -976,6 +976,7 @@ pub struct RemoteSettingsContent {
     pub wsl_connections: Option<Vec<WslConnection>>,
     pub dev_container_connections: Option<Vec<DevContainerConnection>>,
     pub read_ssh_config: Option<bool>,
+    pub use_podman: Option<bool>,
 }
 
 #[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]

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<String>,
         name: Option<String>,
         container_id: Option<String>,
+        use_podman: Option<bool>,
     ) -> Result<RemoteConnectionId> {
         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<HashMap<RemoteConnectionId, RemoteConnectionOptions>> {
         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<RemoteConnectionOptions> {
-        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<String>,
         container_id: Option<String>,
         name: Option<String>,
+        use_podman: Option<bool>,
     ) -> Option<RemoteConnectionOptions> {
         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?,
                 }))
             }
         }

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

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);