Introduce MVP Dev Containers support (#44442)

KyleBarton , Danilo Leal , and Danilo Leal created

Partially addresses #11473 

MVP of dev containers with the following capabilities:

- If in a project with `.devcontainer/devcontainer.json`, a pop-up
notification will ask if you want to open the project in a dev
container. This can be dismissed:
<img width="1478" height="1191" alt="Screenshot 2025-12-08 at 3 15
23 PM"
src="https://github.com/user-attachments/assets/ec2e20d6-28ec-4495-8f23-4c1d48a9ce78"
/>
- Similarly, if a `devcontainer.json` file is in the project, you can
open a devcontainer (or go the devcontainer.json file for further
editing) via the `open remote` modal:


https://github.com/user-attachments/assets/61f2fdaa-2808-4efc-994c-7b444a92c0b1

*Limitations*

This is a first release, and comes with some limitations:
- Zed extensions are not managed in `devcontainer.json` yet. They will
need to be installed either on host or in the container. Host +
Container sync their extensions, so there is not currently a concept of
what is installed in the container vs what is installed on host: they
come from the same list of manifests
- This implementation uses the [devcontainer
CLI](https://github.com/devcontainers/cli) for its control plane. Hence,
it does not yet support the `forwardPorts` directive. A single port can
be opened with `appPort`. See reference in docs
[here](https://github.com/devcontainers/cli/tree/main/example-usage#how-the-tool-examples-work)
- Editing devcontainer.json does not automatically cause the dev
container to be rebuilt. So if you add features, change images, etc, you
will need to `docker kill` the existing dev container before proceeding.
- Currently takes a hard dependency on `docker` being available in the
user's `PATH`.


Release Notes:

- Added ability to Open a project in a DevContainer, provided a
`.devcontainer/devcontainer.json` is present

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
Co-authored-by: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>

Change summary

Cargo.lock                                          |   3 
assets/icons/box.svg                                |   5 
crates/icons/src/icons.rs                           |   1 
crates/languages/src/eslint.rs                      |   4 
crates/node_runtime/src/node_runtime.rs             |  16 
crates/paths/src/paths.rs                           |   6 
crates/recent_projects/Cargo.toml                   |   4 
crates/recent_projects/src/dev_container.rs         | 295 +++++
crates/recent_projects/src/dev_container_suggest.rs | 106 ++
crates/recent_projects/src/recent_projects.rs       |  96 +
crates/recent_projects/src/remote_connections.rs    |  88 +
crates/recent_projects/src/remote_servers.rs        | 530 +++++++++
crates/remote/src/remote.rs                         |   1 
crates/remote/src/remote_client.rs                  |   8 
crates/remote/src/transport.rs                      |  11 
crates/remote/src/transport/docker.rs               | 757 +++++++++++++++
crates/settings/src/settings_content.rs             |  16 
crates/title_bar/src/title_bar.rs                   |  16 
crates/workspace/src/persistence.rs                 |  98 +
crates/workspace/src/persistence/model.rs           |   3 
crates/workspace/src/workspace.rs                   |   6 
crates/zed_actions/src/lib.rs                       |   6 
22 files changed, 1,991 insertions(+), 85 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -13157,6 +13157,7 @@ dependencies = [
  "askpass",
  "auto_update",
  "dap",
+ "db",
  "editor",
  "extension_host",
  "file_finder",
@@ -13168,6 +13169,7 @@ dependencies = [
  "log",
  "markdown",
  "menu",
+ "node_runtime",
  "ordered-float 2.10.1",
  "paths",
  "picker",
@@ -13186,6 +13188,7 @@ dependencies = [
  "util",
  "windows-registry 0.6.1",
  "workspace",
+ "worktree",
  "zed_actions",
 ]
 

assets/icons/box.svg 🔗

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M13.3996 5.59852C13.3994 5.3881 13.3439 5.18144 13.2386 4.99926C13.1333 4.81709 12.9819 4.66581 12.7997 4.56059L8.59996 2.16076C8.41755 2.05544 8.21063 2 8 2C7.78937 2 7.58246 2.05544 7.40004 2.16076L3.20033 4.56059C3.0181 4.66581 2.86674 4.81709 2.76144 4.99926C2.65613 5.18144 2.60059 5.3881 2.60037 5.59852V10.3982C2.60059 10.6086 2.65613 10.8153 2.76144 10.9975C2.86674 11.1796 3.0181 11.3309 3.20033 11.4361L7.40004 13.836C7.58246 13.9413 7.78937 13.9967 8 13.9967C8.21063 13.9967 8.41755 13.9413 8.59996 13.836L12.7997 11.4361C12.9819 11.3309 13.1333 11.1796 13.2386 10.9975C13.3439 10.8153 13.3994 10.6086 13.3996 10.3982V5.59852Z" stroke="white" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M2.78033 4.99857L7.99998 7.99836L13.2196 4.99857" stroke="white" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8 13.9979V7.99829" stroke="white" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

crates/languages/src/eslint.rs 🔗

@@ -126,11 +126,11 @@ impl LspInstaller for EsLintLspAdapter {
             }
 
             self.node
-                .run_npm_subcommand(&repo_root, "install", &[])
+                .run_npm_subcommand(Some(&repo_root), "install", &[])
                 .await?;
 
             self.node
-                .run_npm_subcommand(&repo_root, "run-script", &["compile"])
+                .run_npm_subcommand(Some(&repo_root), "run-script", &["compile"])
                 .await?;
         }
 

crates/node_runtime/src/node_runtime.rs 🔗

@@ -206,14 +206,14 @@ impl NodeRuntime {
 
     pub async fn run_npm_subcommand(
         &self,
-        directory: &Path,
+        directory: Option<&Path>,
         subcommand: &str,
         args: &[&str],
     ) -> Result<Output> {
         let http = self.0.lock().await.http.clone();
         self.instance()
             .await
-            .run_npm_subcommand(Some(directory), http.proxy(), subcommand, args)
+            .run_npm_subcommand(directory, http.proxy(), subcommand, args)
             .await
     }
 
@@ -283,7 +283,7 @@ impl NodeRuntime {
         ]);
 
         // This is also wrong because the directory is wrong.
-        self.run_npm_subcommand(directory, "install", &arguments)
+        self.run_npm_subcommand(Some(directory), "install", &arguments)
             .await?;
         Ok(())
     }
@@ -559,7 +559,10 @@ impl NodeRuntimeTrait for ManagedNodeRuntime {
             command.env("PATH", env_path);
             command.env(NODE_CA_CERTS_ENV_VAR, node_ca_certs);
             command.arg(npm_file).arg(subcommand);
-            command.args(["--cache".into(), self.installation_path.join("cache")]);
+            command.arg(format!(
+                "--cache={}",
+                self.installation_path.join("cache").display()
+            ));
             command.args([
                 "--userconfig".into(),
                 self.installation_path.join("blank_user_npmrc"),
@@ -703,7 +706,10 @@ impl NodeRuntimeTrait for SystemNodeRuntime {
             .env("PATH", path)
             .env(NODE_CA_CERTS_ENV_VAR, node_ca_certs)
             .arg(subcommand)
-            .args(["--cache".into(), self.scratch_dir.join("cache")])
+            .arg(format!(
+                "--cache={}",
+                self.scratch_dir.join("cache").display()
+            ))
             .args(args);
         configure_npm_command(&mut command, directory, proxy);
         let output = command.output().await?;

crates/paths/src/paths.rs 🔗

@@ -408,6 +408,12 @@ pub fn remote_servers_dir() -> &'static PathBuf {
     REMOTE_SERVERS_DIR.get_or_init(|| data_dir().join("remote_servers"))
 }
 
+/// Returns the path to the directory where the devcontainer CLI is installed.
+pub fn devcontainer_dir() -> &'static PathBuf {
+    static DEVCONTAINER_DIR: OnceLock<PathBuf> = OnceLock::new();
+    DEVCONTAINER_DIR.get_or_init(|| data_dir().join("devcontainer"))
+}
+
 /// Returns the relative path to a `.zed` folder within a project.
 pub fn local_settings_folder_name() -> &'static str {
     ".zed"

crates/recent_projects/Cargo.toml 🔗

@@ -16,6 +16,7 @@ doctest = false
 anyhow.workspace = true
 askpass.workspace = true
 auto_update.workspace = true
+db.workspace = true
 editor.workspace = true
 extension_host.workspace = true
 file_finder.workspace = true
@@ -26,6 +27,7 @@ language.workspace = true
 log.workspace = true
 markdown.workspace = true
 menu.workspace = true
+node_runtime.workspace = true
 ordered-float.workspace = true
 paths.workspace = true
 picker.workspace = true
@@ -34,6 +36,7 @@ release_channel.workspace = true
 remote.workspace = true
 semver.workspace = true
 serde.workspace = true
+serde_json.workspace = true
 settings.workspace = true
 smol.workspace = true
 task.workspace = true
@@ -42,6 +45,7 @@ theme.workspace = true
 ui.workspace = true
 util.workspace = true
 workspace.workspace = true
+worktree.workspace = true
 zed_actions.workspace = true
 indoc.workspace = true
 

crates/recent_projects/src/dev_container.rs 🔗

@@ -0,0 +1,295 @@
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+
+use gpui::AsyncWindowContext;
+use node_runtime::NodeRuntime;
+use serde::Deserialize;
+use settings::DevContainerConnection;
+use smol::fs;
+use workspace::Workspace;
+
+use crate::remote_connections::Connection;
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct DevContainerUp {
+    _outcome: String,
+    container_id: String,
+    _remote_user: String,
+    remote_workspace_folder: String,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct DevContainerConfiguration {
+    name: Option<String>,
+}
+
+#[derive(Debug, Deserialize)]
+struct DevContainerConfigurationOutput {
+    configuration: DevContainerConfiguration,
+}
+
+#[cfg(not(target_os = "windows"))]
+fn dev_container_cli() -> String {
+    "devcontainer".to_string()
+}
+
+#[cfg(target_os = "windows")]
+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");
+    command.arg("--version");
+
+    match command.output().await {
+        Ok(_) => Ok(()),
+        Err(e) => {
+            log::error!("Unable to find docker in $PATH: {:?}", e);
+            Err(DevContainerError::DockerNotAvailable)
+        }
+    }
+}
+
+async fn ensure_devcontainer_cli(node_runtime: NodeRuntime) -> Result<PathBuf, DevContainerError> {
+    let mut command = util::command::new_smol_command(&dev_container_cli());
+    command.arg("--version");
+
+    if let Err(e) = command.output().await {
+        log::error!(
+            "Unable to find devcontainer CLI in $PATH. Checking for a zed installed version. Error: {:?}",
+            e
+        );
+
+        let datadir_cli_path = paths::devcontainer_dir()
+            .join("node_modules")
+            .join(".bin")
+            .join(&dev_container_cli());
+
+        let mut command =
+            util::command::new_smol_command(&datadir_cli_path.as_os_str().display().to_string());
+        command.arg("--version");
+
+        if let Err(e) = command.output().await {
+            log::error!(
+                "Unable to find devcontainer CLI in Data dir. Will try to install. Error: {:?}",
+                e
+            );
+        } else {
+            log::info!("Found devcontainer CLI in Data dir");
+            return Ok(datadir_cli_path.clone());
+        }
+
+        if let Err(e) = fs::create_dir_all(paths::devcontainer_dir()).await {
+            log::error!("Unable to create devcontainer directory. Error: {:?}", e);
+            return Err(DevContainerError::DevContainerCliNotAvailable);
+        }
+
+        if let Err(e) = node_runtime
+            .npm_install_packages(
+                &paths::devcontainer_dir(),
+                &[("@devcontainers/cli", "latest")],
+            )
+            .await
+        {
+            log::error!(
+                "Unable to install devcontainer CLI to data directory. Error: {:?}",
+                e
+            );
+            return Err(DevContainerError::DevContainerCliNotAvailable);
+        };
+
+        let mut command = util::command::new_smol_command(&datadir_cli_path.display().to_string());
+        command.arg("--version");
+        if let Err(e) = command.output().await {
+            log::error!(
+                "Unable to find devcontainer cli after NPM install. Error: {:?}",
+                e
+            );
+            Err(DevContainerError::DevContainerCliNotAvailable)
+        } else {
+            Ok(datadir_cli_path)
+        }
+    } else {
+        log::info!("Found devcontainer cli on $PATH, using it");
+        Ok(PathBuf::from(&dev_container_cli()))
+    }
+}
+
+async fn devcontainer_up(
+    path_to_cli: &PathBuf,
+    path: Arc<Path>,
+) -> Result<DevContainerUp, DevContainerError> {
+    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());
+
+    match command.output().await {
+        Ok(output) => {
+            if output.status.success() {
+                let raw = String::from_utf8_lossy(&output.stdout);
+                serde_json::from_str::<DevContainerUp>(&raw).map_err(|e| {
+                    log::error!(
+                        "Unable to parse response from 'devcontainer up' command, error: {:?}",
+                        e
+                    );
+                    DevContainerError::DevContainerParseFailed
+                })
+            } else {
+                log::error!(
+                    "Non-success status running devcontainer up for workspace: out: {:?}, err: {:?}",
+                    String::from_utf8_lossy(&output.stdout),
+                    String::from_utf8_lossy(&output.stderr)
+                );
+                Err(DevContainerError::DevContainerUpFailed)
+            }
+        }
+        Err(e) => {
+            log::error!("Error running devcontainer up: {:?}", e);
+            Err(DevContainerError::DevContainerUpFailed)
+        }
+    }
+}
+
+async fn devcontainer_read_configuration(
+    path_to_cli: &PathBuf,
+    path: Arc<Path>,
+) -> Result<DevContainerConfigurationOutput, DevContainerError> {
+    let mut command = util::command::new_smol_command(path_to_cli.display().to_string());
+    command.arg("read-configuration");
+    command.arg("--workspace-folder");
+    command.arg(path.display().to_string());
+    match command.output().await {
+        Ok(output) => {
+            if output.status.success() {
+                let raw = String::from_utf8_lossy(&output.stdout);
+                serde_json::from_str::<DevContainerConfigurationOutput>(&raw).map_err(|e| {
+                    log::error!(
+                        "Unable to parse response from 'devcontainer read-configuration' command, error: {:?}",
+                        e
+                    );
+                    DevContainerError::DevContainerParseFailed
+                })
+            } else {
+                log::error!(
+                    "Non-success status running devcontainer read-configuration for workspace: out: {:?}, err: {:?}",
+                    String::from_utf8_lossy(&output.stdout),
+                    String::from_utf8_lossy(&output.stderr)
+                );
+                Err(DevContainerError::DevContainerUpFailed)
+            }
+        }
+        Err(e) => {
+            log::error!("Error running devcontainer read-configuration: {:?}", e);
+            Err(DevContainerError::DevContainerUpFailed)
+        }
+    }
+}
+
+// Name the project with two fallbacks
+async fn get_project_name(
+    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)
+    } 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()))
+    }
+}
+
+fn project_directory(cx: &mut AsyncWindowContext) -> Option<Arc<Path>> {
+    let Some(workspace) = cx.window_handle().downcast::<Workspace>() else {
+        return None;
+    };
+
+    match workspace.update(cx, |workspace, _, cx| {
+        workspace.project().read(cx).active_project_directory(cx)
+    }) {
+        Ok(dir) => dir,
+        Err(e) => {
+            log::error!("Error getting project directory from workspace: {:?}", e);
+            None
+        }
+    }
+}
+
+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 = ensure_devcontainer_cli(node_runtime).await?;
+
+    let Some(directory) = project_directory(cx) else {
+        return Err(DevContainerError::DevContainerNotFound);
+    };
+
+    if let Ok(DevContainerUp {
+        container_id,
+        remote_workspace_folder,
+        ..
+    }) = devcontainer_up(&path_to_devcontainer_cli, directory.clone()).await
+    {
+        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.into(),
+            container_id: container_id.into(),
+        });
+
+        Ok((connection, remote_workspace_folder))
+    } else {
+        Err(DevContainerError::DevContainerUpFailed)
+    }
+}
+
+#[derive(Debug)]
+pub(crate) enum DevContainerError {
+    DockerNotAvailable,
+    DevContainerCliNotAvailable,
+    DevContainerUpFailed,
+    DevContainerNotFound,
+    DevContainerParseFailed,
+}
+
+#[cfg(test)]
+mod test {
+
+    use crate::dev_container::DevContainerUp;
+
+    #[test]
+    fn should_parse_from_devcontainer_json() {
+        let json = r#"{"outcome":"success","containerId":"826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/zed"}"#;
+        let up: DevContainerUp = serde_json::from_str(json).unwrap();
+        assert_eq!(up._outcome, "success");
+        assert_eq!(
+            up.container_id,
+            "826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a"
+        );
+        assert_eq!(up._remote_user, "vscode");
+        assert_eq!(up.remote_workspace_folder, "/workspaces/zed");
+    }
+}

crates/recent_projects/src/dev_container_suggest.rs 🔗

@@ -0,0 +1,106 @@
+use db::kvp::KEY_VALUE_STORE;
+use gpui::{SharedString, Window};
+use project::{Project, WorktreeId};
+use std::sync::LazyLock;
+use ui::prelude::*;
+use util::rel_path::RelPath;
+use workspace::Workspace;
+use workspace::notifications::NotificationId;
+use workspace::notifications::simple_message_notification::MessageNotification;
+use worktree::UpdatedEntriesSet;
+
+const DEV_CONTAINER_SUGGEST_KEY: &str = "dev_container_suggest_dismissed";
+
+fn devcontainer_path() -> &'static RelPath {
+    static PATH: LazyLock<&'static RelPath> =
+        LazyLock::new(|| RelPath::unix(".devcontainer").expect("valid path"));
+    *PATH
+}
+
+fn project_devcontainer_key(project_path: &str) -> String {
+    format!("{}_{}", DEV_CONTAINER_SUGGEST_KEY, project_path)
+}
+
+pub fn suggest_on_worktree_updated(
+    worktree_id: WorktreeId,
+    updated_entries: &UpdatedEntriesSet,
+    project: &gpui::Entity<Project>,
+    window: &mut Window,
+    cx: &mut Context<Workspace>,
+) {
+    let devcontainer_updated = updated_entries
+        .iter()
+        .any(|(path, _, _)| path.as_ref() == devcontainer_path());
+
+    if !devcontainer_updated {
+        return;
+    }
+
+    let Some(worktree) = project.read(cx).worktree_for_id(worktree_id, cx) else {
+        return;
+    };
+
+    let worktree = worktree.read(cx);
+
+    if !worktree.is_local() {
+        return;
+    }
+
+    let has_devcontainer = worktree
+        .entry_for_path(devcontainer_path())
+        .is_some_and(|entry| entry.is_dir());
+
+    if !has_devcontainer {
+        return;
+    }
+
+    let abs_path = worktree.abs_path();
+    let project_path = abs_path.to_string_lossy().to_string();
+    let key_for_dismiss = project_devcontainer_key(&project_path);
+
+    let already_dismissed = KEY_VALUE_STORE
+        .read_kvp(&key_for_dismiss)
+        .ok()
+        .flatten()
+        .is_some();
+
+    if already_dismissed {
+        return;
+    }
+
+    cx.on_next_frame(window, move |workspace, _window, cx| {
+        struct DevContainerSuggestionNotification;
+
+        let notification_id = NotificationId::composite::<DevContainerSuggestionNotification>(
+            SharedString::from(project_path.clone()),
+        );
+
+        workspace.show_notification(notification_id, cx, |cx| {
+            cx.new(move |cx| {
+                MessageNotification::new(
+                    "This project contains a Dev Container configuration file. Would you like to re-open it in a container?",
+                    cx,
+                )
+                .primary_message("Yes, Open in Container")
+                .primary_icon(IconName::Check)
+                .primary_icon_color(Color::Success)
+                .primary_on_click({
+                    move |window, cx| {
+                        window.dispatch_action(Box::new(zed_actions::OpenDevContainer), cx);
+                    }
+                })
+                .secondary_message("Don't Show Again")
+                .secondary_icon(IconName::Close)
+                .secondary_icon_color(Color::Error)
+                .secondary_on_click({
+                    move |_window, cx| {
+                        let key = key_for_dismiss.clone();
+                        db::write_and_log(cx, move || {
+                            KEY_VALUE_STORE.write_kvp(key, "dismissed".to_string())
+                        });
+                    }
+                })
+            })
+        });
+    });
+}

crates/recent_projects/src/recent_projects.rs 🔗

@@ -1,8 +1,12 @@
+mod dev_container;
+mod dev_container_suggest;
 pub mod disconnected_overlay;
 mod remote_connections;
 mod remote_servers;
 mod ssh_config;
 
+use std::path::PathBuf;
+
 #[cfg(target_os = "windows")]
 mod wsl_picker;
 
@@ -31,7 +35,7 @@ use workspace::{
     WORKSPACE_DB, Workspace, WorkspaceId, notifications::DetachAndPromptErr,
     with_active_or_new_workspace,
 };
-use zed_actions::{OpenRecent, OpenRemote};
+use zed_actions::{OpenDevContainer, OpenRecent, OpenRemote};
 
 pub fn init(cx: &mut App) {
     #[cfg(target_os = "windows")]
@@ -161,6 +165,95 @@ pub fn init(cx: &mut App) {
     });
 
     cx.observe_new(DisconnectedOverlay::register).detach();
+
+    cx.on_action(|_: &OpenDevContainer, cx| {
+        with_active_or_new_workspace(cx, move |workspace, window, cx| {
+            let app_state = workspace.app_state().clone();
+            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 result = open_remote_project(
+                    connection.into(),
+                    vec![starting_dir].into_iter().map(PathBuf::from).collect(),
+                    app_state,
+                    OpenOptions {
+                        replace_window,
+                        ..OpenOptions::default()
+                    },
+                    &mut cx,
+                )
+                .await;
+
+                if let Err(e) = result {
+                    log::error!("Failed to connect: {e:#}");
+                    cx.prompt(
+                        gpui::PromptLevel::Critical,
+                        "Failed to connect",
+                        Some(&e.to_string()),
+                        &["Ok"],
+                    )
+                    .await
+                    .ok();
+                }
+            })
+            .detach();
+
+            let fs = workspace.project().read(cx).fs().clone();
+            let handle = cx.entity().downgrade();
+            workspace.toggle_modal(window, cx, |window, cx| {
+                RemoteServerProjects::new_dev_container(fs, window, handle, cx)
+            });
+        });
+    });
+
+    // Subscribe to worktree additions to suggest opening the project in a dev container
+    cx.observe_new(
+        |workspace: &mut Workspace, window: Option<&mut Window>, cx: &mut Context<Workspace>| {
+            let Some(window) = window else {
+                return;
+            };
+            cx.subscribe_in(
+                workspace.project(),
+                window,
+                move |_, project, event, window, cx| {
+                    if let project::Event::WorktreeUpdatedEntries(worktree_id, updated_entries) =
+                        event
+                    {
+                        dev_container_suggest::suggest_on_worktree_updated(
+                            *worktree_id,
+                            updated_entries,
+                            project,
+                            window,
+                            cx,
+                        );
+                    }
+                },
+            )
+            .detach();
+        },
+    )
+    .detach();
 }
 
 #[cfg(target_os = "windows")]
@@ -609,6 +702,7 @@ impl PickerDelegate for RecentProjectsDelegate {
                                     Icon::new(match options {
                                         RemoteConnectionOptions::Ssh { .. } => IconName::Server,
                                         RemoteConnectionOptions::Wsl { .. } => IconName::Linux,
+                                        RemoteConnectionOptions::Docker(_) => IconName::Box,
                                     })
                                     .color(Color::Muted)
                                     .into_any_element()

crates/recent_projects/src/remote_connections.rs 🔗

@@ -18,16 +18,16 @@ use language::{CursorShape, Point};
 use markdown::{Markdown, MarkdownElement, MarkdownStyle};
 use release_channel::ReleaseChannel;
 use remote::{
-    ConnectionIdentifier, RemoteClient, RemoteConnection, RemoteConnectionOptions, RemotePlatform,
-    SshConnectionOptions,
+    ConnectionIdentifier, DockerConnectionOptions, RemoteClient, RemoteConnection,
+    RemoteConnectionOptions, RemotePlatform, SshConnectionOptions,
 };
 use semver::Version;
 pub use settings::SshConnection;
-use settings::{ExtendingVec, RegisterSetting, Settings, WslConnection};
+use settings::{DevContainerConnection, ExtendingVec, RegisterSetting, Settings, WslConnection};
 use theme::ThemeSettings;
 use ui::{
-    ActiveTheme, Color, CommonAnimationExt, Context, Icon, IconName, IconSize, InteractiveElement,
-    IntoElement, Label, LabelCommon, Styled, Window, prelude::*,
+    ActiveTheme, Color, CommonAnimationExt, Context, InteractiveElement, IntoElement, KeyBinding,
+    LabelCommon, ListItem, Styled, Window, prelude::*,
 };
 use util::paths::PathWithPosition;
 use workspace::{AppState, ModalView, Workspace};
@@ -85,6 +85,7 @@ impl SshSettings {
 pub enum Connection {
     Ssh(SshConnection),
     Wsl(WslConnection),
+    DevContainer(DevContainerConnection),
 }
 
 impl From<Connection> for RemoteConnectionOptions {
@@ -92,6 +93,13 @@ impl From<Connection> for RemoteConnectionOptions {
         match val {
             Connection::Ssh(conn) => RemoteConnectionOptions::Ssh(conn.into()),
             Connection::Wsl(conn) => RemoteConnectionOptions::Wsl(conn.into()),
+            Connection::DevContainer(conn) => {
+                RemoteConnectionOptions::Docker(DockerConnectionOptions {
+                    name: conn.name.to_string(),
+                    container_id: conn.container_id.to_string(),
+                    upload_binary_over_docker_exec: false,
+                })
+            }
         }
     }
 }
@@ -123,6 +131,7 @@ pub struct RemoteConnectionPrompt {
     connection_string: SharedString,
     nickname: Option<SharedString>,
     is_wsl: bool,
+    is_devcontainer: bool,
     status_message: Option<SharedString>,
     prompt: Option<(Entity<Markdown>, oneshot::Sender<EncryptedPassword>)>,
     cancellation: Option<oneshot::Sender<()>>,
@@ -148,6 +157,7 @@ impl RemoteConnectionPrompt {
         connection_string: String,
         nickname: Option<String>,
         is_wsl: bool,
+        is_devcontainer: bool,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
@@ -155,6 +165,7 @@ impl RemoteConnectionPrompt {
             connection_string: connection_string.into(),
             nickname: nickname.map(|nickname| nickname.into()),
             is_wsl,
+            is_devcontainer,
             editor: cx.new(|cx| Editor::single_line(window, cx)),
             status_message: None,
             cancellation: None,
@@ -244,17 +255,16 @@ impl Render for RemoteConnectionPrompt {
 
         v_flex()
             .key_context("PasswordPrompt")
-            .py_2()
-            .px_3()
+            .p_2()
             .size_full()
             .text_buffer(cx)
             .when_some(self.status_message.clone(), |el, status_message| {
                 el.child(
                     h_flex()
-                        .gap_1()
+                        .gap_2()
                         .child(
                             Icon::new(IconName::ArrowCircle)
-                                .size(IconSize::Medium)
+                                .color(Color::Muted)
                                 .with_rotate_animation(2),
                         )
                         .child(
@@ -287,15 +297,28 @@ impl RemoteConnectionModal {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
-        let (connection_string, nickname, is_wsl) = match connection_options {
-            RemoteConnectionOptions::Ssh(options) => {
-                (options.connection_string(), options.nickname.clone(), false)
+        let (connection_string, nickname, is_wsl, is_devcontainer) = match connection_options {
+            RemoteConnectionOptions::Ssh(options) => (
+                options.connection_string(),
+                options.nickname.clone(),
+                false,
+                false,
+            ),
+            RemoteConnectionOptions::Wsl(options) => {
+                (options.distro_name.clone(), None, true, false)
             }
-            RemoteConnectionOptions::Wsl(options) => (options.distro_name.clone(), None, true),
+            RemoteConnectionOptions::Docker(options) => (options.name.clone(), None, false, true),
         };
         Self {
             prompt: cx.new(|cx| {
-                RemoteConnectionPrompt::new(connection_string, nickname, is_wsl, window, cx)
+                RemoteConnectionPrompt::new(
+                    connection_string,
+                    nickname,
+                    is_wsl,
+                    is_devcontainer,
+                    window,
+                    cx,
+                )
             }),
             finished: false,
             paths,
@@ -328,6 +351,7 @@ pub(crate) struct SshConnectionHeader {
     pub(crate) paths: Vec<PathBuf>,
     pub(crate) nickname: Option<SharedString>,
     pub(crate) is_wsl: bool,
+    pub(crate) is_devcontainer: bool,
 }
 
 impl RenderOnce for SshConnectionHeader {
@@ -343,9 +367,12 @@ impl RenderOnce for SshConnectionHeader {
             (self.connection_string, None)
         };
 
-        let icon = match self.is_wsl {
-            true => IconName::Linux,
-            false => IconName::Server,
+        let icon = if self.is_wsl {
+            IconName::Linux
+        } else if self.is_devcontainer {
+            IconName::Box
+        } else {
+            IconName::Server
         };
 
         h_flex()
@@ -388,6 +415,7 @@ impl Render for RemoteConnectionModal {
         let nickname = self.prompt.read(cx).nickname.clone();
         let connection_string = self.prompt.read(cx).connection_string.clone();
         let is_wsl = self.prompt.read(cx).is_wsl;
+        let is_devcontainer = self.prompt.read(cx).is_devcontainer;
 
         let theme = cx.theme().clone();
         let body_color = theme.colors().editor_background;
@@ -407,18 +435,34 @@ impl Render for RemoteConnectionModal {
                     connection_string,
                     nickname,
                     is_wsl,
+                    is_devcontainer,
                 }
                 .render(window, cx),
             )
             .child(
                 div()
                     .w_full()
-                    .rounded_b_lg()
                     .bg(body_color)
-                    .border_t_1()
+                    .border_y_1()
                     .border_color(theme.colors().border_variant)
                     .child(self.prompt.clone()),
             )
+            .child(
+                div().w_full().py_1().child(
+                    ListItem::new("li-devcontainer-go-back")
+                        .inset(true)
+                        .spacing(ui::ListItemSpacing::Sparse)
+                        .start_slot(Icon::new(IconName::Close).color(Color::Muted))
+                        .child(Label::new("Cancel"))
+                        .end_slot(
+                            KeyBinding::for_action_in(&menu::Cancel, &self.focus_handle(cx), cx)
+                                .size(rems_from_px(12.)),
+                        )
+                        .on_click(cx.listener(|this, _, window, cx| {
+                            this.dismiss(&menu::Cancel, window, cx);
+                        })),
+                ),
+            )
     }
 }
 
@@ -671,6 +715,9 @@ pub async fn open_remote_project(
                                 match connection_options {
                                     RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
                                     RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
+                                    RemoteConnectionOptions::Docker(_) => {
+                                        "Failed to connect to Dev Container"
+                                    }
                                 },
                                 Some(&format!("{e:#}")),
                                 &["Retry", "Cancel"],
@@ -727,6 +774,9 @@ pub async fn open_remote_project(
                             match connection_options {
                                 RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
                                 RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
+                                RemoteConnectionOptions::Docker(_) => {
+                                    "Failed to connect to Dev Container"
+                                }
                             },
                             Some(&format!("{e:#}")),
                             &["Retry", "Cancel"],

crates/recent_projects/src/remote_servers.rs 🔗

@@ -1,4 +1,5 @@
 use crate::{
+    dev_container::start_dev_container,
     remote_connections::{
         Connection, RemoteConnectionModal, RemoteConnectionPrompt, SshConnection,
         SshConnectionHeader, SshSettings, connect, determine_paths_with_positions,
@@ -24,7 +25,7 @@ use remote::{
     remote_client::ConnectionIdentifier,
 };
 use settings::{
-    RemoteSettingsContent, Settings as _, SettingsStore, SshProject, update_settings_file,
+    RemoteProject, RemoteSettingsContent, Settings as _, SettingsStore, update_settings_file,
     watch_config_file,
 };
 use smol::stream::StreamExt as _;
@@ -39,12 +40,13 @@ use std::{
     },
 };
 use ui::{
-    IconButtonShape, List, ListItem, ListSeparator, Modal, ModalHeader, Navigable, NavigableEntry,
-    Section, Tooltip, WithScrollbar, prelude::*,
+    CommonAnimationExt, IconButtonShape, KeyBinding, List, ListItem, ListSeparator, Modal,
+    ModalHeader, Navigable, NavigableEntry, Section, Tooltip, WithScrollbar, prelude::*,
 };
 use util::{
     ResultExt,
     paths::{PathStyle, RemotePathBuf},
+    rel_path::RelPath,
 };
 use workspace::{
     ModalView, OpenOptions, Toast, Workspace,
@@ -85,6 +87,39 @@ impl CreateRemoteServer {
     }
 }
 
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+enum DevContainerCreationProgress {
+    Initial,
+    Creating,
+    Error(String),
+}
+
+#[derive(Clone)]
+struct CreateRemoteDevContainer {
+    // 3 Navigable Options
+    // - Create from devcontainer.json
+    // - Edit devcontainer.json
+    // - Go back
+    entries: [NavigableEntry; 3],
+    progress: DevContainerCreationProgress,
+}
+
+impl CreateRemoteDevContainer {
+    fn new(window: &mut Window, cx: &mut Context<RemoteServerProjects>) -> Self {
+        let entries = std::array::from_fn(|_| NavigableEntry::focusable(cx));
+        entries[0].focus_handle.focus(window);
+        Self {
+            entries,
+            progress: DevContainerCreationProgress::Initial,
+        }
+    }
+
+    fn progress(&mut self, progress: DevContainerCreationProgress) -> Self {
+        self.progress = progress;
+        self.clone()
+    }
+}
+
 #[cfg(target_os = "windows")]
 struct AddWslDistro {
     picker: Entity<Picker<crate::wsl_picker::WslPickerDelegate>>,
@@ -207,6 +242,11 @@ impl ProjectPicker {
             RemoteConnectionOptions::Wsl(connection) => ProjectPickerData::Wsl {
                 distro_name: connection.distro_name.clone().into(),
             },
+            RemoteConnectionOptions::Docker(_) => ProjectPickerData::Ssh {
+                // Not implemented as a project picker at this time
+                connection_string: "".into(),
+                nickname: None,
+            },
         };
         let _path_task = cx
             .spawn_in(window, {
@@ -259,7 +299,7 @@ impl ProjectPicker {
                                         .as_mut()
                                         .and_then(|connections| connections.get_mut(index.0))
                                     {
-                                        server.projects.insert(SshProject { paths });
+                                        server.projects.insert(RemoteProject { paths });
                                     };
                                 }
                                 ServerIndex::Wsl(index) => {
@@ -269,7 +309,7 @@ impl ProjectPicker {
                                         .as_mut()
                                         .and_then(|connections| connections.get_mut(index.0))
                                     {
-                                        server.projects.insert(SshProject { paths });
+                                        server.projects.insert(RemoteProject { paths });
                                     };
                                 }
                             }
@@ -349,6 +389,7 @@ impl gpui::Render for ProjectPicker {
                     paths: Default::default(),
                     nickname: nickname.clone(),
                     is_wsl: false,
+                    is_devcontainer: false,
                 }
                 .render(window, cx),
                 ProjectPickerData::Wsl { distro_name } => SshConnectionHeader {
@@ -356,6 +397,7 @@ impl gpui::Render for ProjectPicker {
                     paths: Default::default(),
                     nickname: None,
                     is_wsl: true,
+                    is_devcontainer: false,
                 }
                 .render(window, cx),
             })
@@ -406,7 +448,7 @@ impl From<WslServerIndex> for ServerIndex {
 enum RemoteEntry {
     Project {
         open_folder: NavigableEntry,
-        projects: Vec<(NavigableEntry, SshProject)>,
+        projects: Vec<(NavigableEntry, RemoteProject)>,
         configure: NavigableEntry,
         connection: Connection,
         index: ServerIndex,
@@ -440,6 +482,7 @@ impl RemoteEntry {
 struct DefaultState {
     scroll_handle: ScrollHandle,
     add_new_server: NavigableEntry,
+    add_new_devcontainer: NavigableEntry,
     add_new_wsl: NavigableEntry,
     servers: Vec<RemoteEntry>,
 }
@@ -448,6 +491,7 @@ impl DefaultState {
     fn new(ssh_config_servers: &BTreeSet<SharedString>, cx: &mut App) -> Self {
         let handle = ScrollHandle::new();
         let add_new_server = NavigableEntry::new(&handle, cx);
+        let add_new_devcontainer = NavigableEntry::new(&handle, cx);
         let add_new_wsl = NavigableEntry::new(&handle, cx);
 
         let ssh_settings = SshSettings::get_global(cx);
@@ -517,6 +561,7 @@ impl DefaultState {
         Self {
             scroll_handle: handle,
             add_new_server,
+            add_new_devcontainer,
             add_new_wsl,
             servers,
         }
@@ -552,6 +597,7 @@ enum Mode {
     EditNickname(EditNicknameState),
     ProjectPicker(Entity<ProjectPicker>),
     CreateRemoteServer(CreateRemoteServer),
+    CreateRemoteDevContainer(CreateRemoteDevContainer),
     #[cfg(target_os = "windows")]
     AddWslDistro(AddWslDistro),
 }
@@ -598,6 +644,27 @@ impl RemoteServerProjects {
         )
     }
 
+    /// Creates a new RemoteServerProjects modal that opens directly in dev container creation mode.
+    /// Used when suggesting dev container connection from toast notification.
+    pub fn new_dev_container(
+        fs: Arc<dyn Fs>,
+        window: &mut Window,
+        workspace: WeakEntity<Workspace>,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        Self::new_inner(
+            Mode::CreateRemoteDevContainer(
+                CreateRemoteDevContainer::new(window, cx)
+                    .progress(DevContainerCreationProgress::Creating),
+            ),
+            false,
+            fs,
+            window,
+            workspace,
+            cx,
+        )
+    }
+
     fn new_inner(
         mode: Mode,
         create_new_window: bool,
@@ -703,6 +770,7 @@ impl RemoteServerProjects {
                 connection_options.connection_string(),
                 connection_options.nickname.clone(),
                 false,
+                false,
                 window,
                 cx,
             )
@@ -778,6 +846,7 @@ impl RemoteServerProjects {
                 connection_options.distro_name.clone(),
                 None,
                 true,
+                false,
                 window,
                 cx,
             )
@@ -862,6 +931,15 @@ impl RemoteServerProjects {
         cx.notify();
     }
 
+    fn view_in_progress_dev_container(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        self.mode = Mode::CreateRemoteDevContainer(
+            CreateRemoteDevContainer::new(window, cx)
+                .progress(DevContainerCreationProgress::Creating),
+        );
+        self.focus_handle(cx).focus(window);
+        cx.notify();
+    }
+
     fn create_remote_project(
         &mut self,
         index: ServerIndex,
@@ -981,6 +1059,7 @@ impl RemoteServerProjects {
 
                 self.create_ssh_server(state.address_editor.clone(), window, cx);
             }
+            Mode::CreateRemoteDevContainer(_) => {}
             Mode::EditNickname(state) => {
                 let text = Some(state.editor.read(cx).text(cx)).filter(|text| !text.is_empty());
                 let index = state.index;
@@ -1024,14 +1103,14 @@ impl RemoteServerProjects {
         }
     }
 
-    fn render_ssh_connection(
+    fn render_remote_connection(
         &mut self,
         ix: usize,
-        ssh_server: RemoteEntry,
+        remote_server: RemoteEntry,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> impl IntoElement {
-        let connection = ssh_server.connection().into_owned();
+        let connection = remote_server.connection().into_owned();
 
         let (main_label, aux_label, is_wsl) = match &connection {
             Connection::Ssh(connection) => {
@@ -1045,6 +1124,9 @@ impl RemoteServerProjects {
             Connection::Wsl(wsl_connection_options) => {
                 (wsl_connection_options.distro_name.clone(), None, true)
             }
+            Connection::DevContainer(dev_container_options) => {
+                (dev_container_options.name.clone(), None, false)
+            }
         };
         v_flex()
             .w_full()
@@ -1082,7 +1164,7 @@ impl RemoteServerProjects {
                         }),
                     ),
             )
-            .child(match &ssh_server {
+            .child(match &remote_server {
                 RemoteEntry::Project {
                     open_folder,
                     projects,
@@ -1094,9 +1176,9 @@ impl RemoteServerProjects {
                     List::new()
                         .empty_message("No projects.")
                         .children(projects.iter().enumerate().map(|(pix, p)| {
-                            v_flex().gap_0p5().child(self.render_ssh_project(
+                            v_flex().gap_0p5().child(self.render_remote_project(
                                 index,
-                                ssh_server.clone(),
+                                remote_server.clone(),
                                 pix,
                                 p,
                                 window,
@@ -1222,12 +1304,12 @@ impl RemoteServerProjects {
             })
     }
 
-    fn render_ssh_project(
+    fn render_remote_project(
         &mut self,
         server_ix: ServerIndex,
         server: RemoteEntry,
         ix: usize,
-        (navigation, project): &(NavigableEntry, SshProject),
+        (navigation, project): &(NavigableEntry, RemoteProject),
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> impl IntoElement {
@@ -1372,7 +1454,7 @@ impl RemoteServerProjects {
     fn delete_remote_project(
         &mut self,
         server: ServerIndex,
-        project: &SshProject,
+        project: &RemoteProject,
         cx: &mut Context<Self>,
     ) {
         match server {
@@ -1388,7 +1470,7 @@ impl RemoteServerProjects {
     fn delete_ssh_project(
         &mut self,
         server: SshServerIndex,
-        project: &SshProject,
+        project: &RemoteProject,
         cx: &mut Context<Self>,
     ) {
         let project = project.clone();
@@ -1406,7 +1488,7 @@ impl RemoteServerProjects {
     fn delete_wsl_project(
         &mut self,
         server: WslServerIndex,
-        project: &SshProject,
+        project: &RemoteProject,
         cx: &mut Context<Self>,
     ) {
         let project = project.clone();
@@ -1451,6 +1533,342 @@ impl RemoteServerProjects {
         });
     }
 
+    fn edit_in_dev_container_json(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        let Some(workspace) = self.workspace.upgrade() else {
+            cx.emit(DismissEvent);
+            cx.notify();
+            return;
+        };
+
+        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));
+
+            if let Some(worktree) = worktree {
+                let tree_id = worktree.read(cx).id();
+                let devcontainer_path = RelPath::unix(".devcontainer/devcontainer.json").unwrap();
+                cx.spawn_in(window, async move |workspace, cx| {
+                    workspace
+                        .update_in(cx, |workspace, window, cx| {
+                            workspace.open_path(
+                                (tree_id, devcontainer_path),
+                                None,
+                                true,
+                                window,
+                                cx,
+                            )
+                        })?
+                        .await
+                })
+                .detach();
+            } else {
+                return;
+            }
+        });
+        cx.emit(DismissEvent);
+        cx.notify();
+    }
+
+    fn open_dev_container(&self, window: &mut Window, cx: &mut Context<Self>) {
+        let Some(app_state) = self
+            .workspace
+            .read_with(cx, |workspace, _| workspace.app_state().clone())
+            .log_err()
+        else {
+            return;
+        };
+
+        let replace_window = window.window_handle().downcast::<Workspace>();
+
+        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),
+                    Err(e) => {
+                        log::error!("Failed to start dev container: {:?}", e);
+                        entity
+                            .update_in(cx, |remote_server_projects, window, cx| {
+                                remote_server_projects.mode = Mode::CreateRemoteDevContainer(
+                                    CreateRemoteDevContainer::new(window, cx).progress(
+                                        DevContainerCreationProgress::Error(format!("{:?}", e)),
+                                    ),
+                                );
+                            })
+                            .log_err();
+                        return;
+                    }
+                };
+            entity
+                .update(cx, |_, cx| {
+                    cx.emit(DismissEvent);
+                })
+                .log_err();
+
+            let result = open_remote_project(
+                connection.into(),
+                vec![starting_dir].into_iter().map(PathBuf::from).collect(),
+                app_state,
+                OpenOptions {
+                    replace_window,
+                    ..OpenOptions::default()
+                },
+                cx,
+            )
+            .await;
+            if let Err(e) = result {
+                log::error!("Failed to connect: {e:#}");
+                cx.prompt(
+                    gpui::PromptLevel::Critical,
+                    "Failed to connect",
+                    Some(&e.to_string()),
+                    &["Ok"],
+                )
+                .await
+                .ok();
+            }
+        })
+        .detach();
+    }
+
+    fn render_create_dev_container(
+        &self,
+        state: &CreateRemoteDevContainer,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> impl IntoElement {
+        match &state.progress {
+            DevContainerCreationProgress::Error(message) => {
+                self.focus_handle(cx).focus(window);
+                return div()
+                    .track_focus(&self.focus_handle(cx))
+                    .size_full()
+                    .child(
+                        v_flex()
+                            .py_1()
+                            .child(
+                                ListItem::new("Error")
+                                    .inset(true)
+                                    .selectable(false)
+                                    .spacing(ui::ListItemSpacing::Sparse)
+                                    .start_slot(Icon::new(IconName::XCircle).color(Color::Error))
+                                    .child(Label::new("Error Creating Dev Container:"))
+                                    .child(Label::new(message).buffer_font(cx)),
+                            )
+                            .child(ListSeparator)
+                            .child(
+                                div()
+                                    .id("devcontainer-go-back")
+                                    .track_focus(&state.entries[0].focus_handle)
+                                    .on_action(cx.listener(
+                                        |this, _: &menu::Confirm, window, cx| {
+                                            this.mode =
+                                                Mode::default_mode(&this.ssh_config_servers, cx);
+                                            cx.focus_self(window);
+                                            cx.notify();
+                                        },
+                                    ))
+                                    .child(
+                                        ListItem::new("li-devcontainer-go-back")
+                                            .toggle_state(
+                                                state.entries[0]
+                                                    .focus_handle
+                                                    .contains_focused(window, cx),
+                                            )
+                                            .inset(true)
+                                            .spacing(ui::ListItemSpacing::Sparse)
+                                            .start_slot(
+                                                Icon::new(IconName::ArrowLeft).color(Color::Muted),
+                                            )
+                                            .child(Label::new("Go Back"))
+                                            .end_slot(
+                                                KeyBinding::for_action_in(
+                                                    &menu::Cancel,
+                                                    &self.focus_handle,
+                                                    cx,
+                                                )
+                                                .size(rems_from_px(12.)),
+                                            )
+                                            .on_click(cx.listener(|this, _, window, cx| {
+                                                let state =
+                                                    CreateRemoteDevContainer::new(window, cx);
+                                                this.mode = Mode::CreateRemoteDevContainer(state);
+
+                                                cx.notify();
+                                            })),
+                                    ),
+                            ),
+                    )
+                    .into_any_element();
+            }
+            _ => {}
+        };
+
+        let mut view = Navigable::new(
+            div()
+                .track_focus(&self.focus_handle(cx))
+                .size_full()
+                .child(
+                    v_flex()
+                        .pb_1()
+                        .child(
+                            ModalHeader::new()
+                                .child(Headline::new("Dev Containers").size(HeadlineSize::XSmall)),
+                        )
+                        .child(ListSeparator)
+                        .child(
+                            div()
+                                .id("confirm-create-from-devcontainer-json")
+                                .track_focus(&state.entries[0].focus_handle)
+                                .on_action(cx.listener({
+                                    move |this, _: &menu::Confirm, window, cx| {
+                                        this.open_dev_container(window, cx);
+                                        this.view_in_progress_dev_container(window, cx);
+                                    }
+                                }))
+                                .map(|this| {
+                                    if state.progress == DevContainerCreationProgress::Creating {
+                                        this.child(
+                                            ListItem::new("creating")
+                                                .inset(true)
+                                                .spacing(ui::ListItemSpacing::Sparse)
+                                                .disabled(true)
+                                                .start_slot(
+                                                    Icon::new(IconName::ArrowCircle)
+                                                        .color(Color::Muted)
+                                                        .with_rotate_animation(2),
+                                                )
+                                                .child(
+                                                    h_flex()
+                                                        .opacity(0.6)
+                                                        .gap_1()
+                                                        .child(Label::new("Creating From"))
+                                                        .child(
+                                                            Label::new("devcontainer.json")
+                                                                .buffer_font(cx),
+                                                        )
+                                                        .child(LoadingLabel::new("")),
+                                                ),
+                                        )
+                                    } else {
+                                        this.child(
+                                            ListItem::new(
+                                                "li-confirm-create-from-devcontainer-json",
+                                            )
+                                            .toggle_state(
+                                                state.entries[0]
+                                                    .focus_handle
+                                                    .contains_focused(window, cx),
+                                            )
+                                            .inset(true)
+                                            .spacing(ui::ListItemSpacing::Sparse)
+                                            .start_slot(
+                                                Icon::new(IconName::Plus).color(Color::Muted),
+                                            )
+                                            .child(
+                                                h_flex()
+                                                    .gap_1()
+                                                    .child(Label::new("Open or Create New From"))
+                                                    .child(
+                                                        Label::new("devcontainer.json")
+                                                            .buffer_font(cx),
+                                                    ),
+                                            )
+                                            .on_click(
+                                                cx.listener({
+                                                    move |this, _, window, cx| {
+                                                        this.open_dev_container(window, cx);
+                                                        this.view_in_progress_dev_container(
+                                                            window, cx,
+                                                        );
+                                                        cx.notify();
+                                                    }
+                                                }),
+                                            ),
+                                        )
+                                    }
+                                }),
+                        )
+                        .child(
+                            div()
+                                .id("edit-devcontainer-json")
+                                .track_focus(&state.entries[1].focus_handle)
+                                .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
+                                    this.edit_in_dev_container_json(window, cx);
+                                }))
+                                .child(
+                                    ListItem::new("li-edit-devcontainer-json")
+                                        .toggle_state(
+                                            state.entries[1]
+                                                .focus_handle
+                                                .contains_focused(window, cx),
+                                        )
+                                        .inset(true)
+                                        .spacing(ui::ListItemSpacing::Sparse)
+                                        .start_slot(Icon::new(IconName::Pencil).color(Color::Muted))
+                                        .child(
+                                            h_flex().gap_1().child(Label::new("Edit")).child(
+                                                Label::new("devcontainer.json").buffer_font(cx),
+                                            ),
+                                        )
+                                        .on_click(cx.listener(move |this, _, window, cx| {
+                                            this.edit_in_dev_container_json(window, cx);
+                                        })),
+                                ),
+                        )
+                        .child(ListSeparator)
+                        .child(
+                            div()
+                                .id("devcontainer-go-back")
+                                .track_focus(&state.entries[2].focus_handle)
+                                .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
+                                    this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
+                                    cx.focus_self(window);
+                                    cx.notify();
+                                }))
+                                .child(
+                                    ListItem::new("li-devcontainer-go-back")
+                                        .toggle_state(
+                                            state.entries[2]
+                                                .focus_handle
+                                                .contains_focused(window, cx),
+                                        )
+                                        .inset(true)
+                                        .spacing(ui::ListItemSpacing::Sparse)
+                                        .start_slot(
+                                            Icon::new(IconName::ArrowLeft).color(Color::Muted),
+                                        )
+                                        .child(Label::new("Go Back"))
+                                        .end_slot(
+                                            KeyBinding::for_action_in(
+                                                &menu::Cancel,
+                                                &self.focus_handle,
+                                                cx,
+                                            )
+                                            .size(rems_from_px(12.)),
+                                        )
+                                        .on_click(cx.listener(|this, _, window, cx| {
+                                            this.mode =
+                                                Mode::default_mode(&this.ssh_config_servers, cx);
+                                            cx.focus_self(window);
+                                            cx.notify()
+                                        })),
+                                ),
+                        ),
+                )
+                .into_any_element(),
+        );
+
+        view = view.entry(state.entries[0].clone());
+        view = view.entry(state.entries[1].clone());
+        view = view.entry(state.entries[2].clone());
+
+        view.render(window, cx).into_any_element()
+    }
+
     fn render_create_remote_server(
         &self,
         state: &CreateRemoteServer,
@@ -1571,6 +1989,7 @@ impl RemoteServerProjects {
                         paths: Default::default(),
                         nickname: connection.nickname.clone().map(|s| s.into()),
                         is_wsl: false,
+                        is_devcontainer: false,
                     }
                     .render(window, cx)
                     .into_any_element(),
@@ -1579,6 +1998,7 @@ impl RemoteServerProjects {
                         paths: Default::default(),
                         nickname: None,
                         is_wsl: true,
+                        is_devcontainer: false,
                     }
                     .render(window, cx)
                     .into_any_element(),
@@ -1917,6 +2337,7 @@ impl RemoteServerProjects {
                     paths: Default::default(),
                     nickname,
                     is_wsl: false,
+                    is_devcontainer: false,
                 }
                 .render(window, cx),
             )
@@ -1998,7 +2419,7 @@ impl RemoteServerProjects {
             .track_focus(&state.add_new_server.focus_handle)
             .anchor_scroll(state.add_new_server.scroll_anchor.clone())
             .child(
-                ListItem::new("register-remove-server-button")
+                ListItem::new("register-remote-server-button")
                     .toggle_state(
                         state
                             .add_new_server
@@ -2008,7 +2429,7 @@ impl RemoteServerProjects {
                     .inset(true)
                     .spacing(ui::ListItemSpacing::Sparse)
                     .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
-                    .child(Label::new("Connect New Server"))
+                    .child(Label::new("Connect SSH Server"))
                     .on_click(cx.listener(|this, _, window, cx| {
                         let state = CreateRemoteServer::new(window, cx);
                         this.mode = Mode::CreateRemoteServer(state);
@@ -2023,6 +2444,36 @@ impl RemoteServerProjects {
                 cx.notify();
             }));
 
+        let connect_dev_container_button = div()
+            .id("connect-new-dev-container")
+            .track_focus(&state.add_new_devcontainer.focus_handle)
+            .anchor_scroll(state.add_new_devcontainer.scroll_anchor.clone())
+            .child(
+                ListItem::new("register-dev-container-button")
+                    .toggle_state(
+                        state
+                            .add_new_devcontainer
+                            .focus_handle
+                            .contains_focused(window, cx),
+                    )
+                    .inset(true)
+                    .spacing(ui::ListItemSpacing::Sparse)
+                    .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
+                    .child(Label::new("Connect Dev Container"))
+                    .on_click(cx.listener(|this, _, window, cx| {
+                        let state = CreateRemoteDevContainer::new(window, cx);
+                        this.mode = Mode::CreateRemoteDevContainer(state);
+
+                        cx.notify();
+                    })),
+            )
+            .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
+                let state = CreateRemoteDevContainer::new(window, cx);
+                this.mode = Mode::CreateRemoteDevContainer(state);
+
+                cx.notify();
+            }));
+
         #[cfg(target_os = "windows")]
         let wsl_connect_button = div()
             .id("wsl-connect-new-server")
@@ -2049,13 +2500,30 @@ impl RemoteServerProjects {
                 cx.notify();
             }));
 
+        let has_open_project = self
+            .workspace
+            .upgrade()
+            .map(|workspace| {
+                workspace
+                    .read(cx)
+                    .project()
+                    .read(cx)
+                    .visible_worktrees(cx)
+                    .next()
+                    .is_some()
+            })
+            .unwrap_or(false);
+
         let modal_section = v_flex()
             .track_focus(&self.focus_handle(cx))
             .id("ssh-server-list")
             .overflow_y_scroll()
             .track_scroll(&state.scroll_handle)
             .size_full()
-            .child(connect_button);
+            .child(connect_button)
+            .when(has_open_project, |this| {
+                this.child(connect_dev_container_button)
+            });
 
         #[cfg(target_os = "windows")]
         let modal_section = modal_section.child(wsl_connect_button);
@@ -2067,17 +2535,20 @@ impl RemoteServerProjects {
                 .child(
                     List::new()
                         .empty_message(
-                            v_flex()
+                            h_flex()
+                                .size_full()
+                                .p_2()
+                                .justify_center()
+                                .border_t_1()
+                                .border_color(cx.theme().colors().border_variant)
                                 .child(
-                                    div().px_3().child(
-                                        Label::new("No remote servers registered yet.")
-                                            .color(Color::Muted),
-                                    ),
+                                    Label::new("No remote servers registered yet.")
+                                        .color(Color::Muted),
                                 )
                                 .into_any_element(),
                         )
                         .children(state.servers.iter().enumerate().map(|(ix, connection)| {
-                            self.render_ssh_connection(ix, connection.clone(), window, cx)
+                            self.render_remote_connection(ix, connection.clone(), window, cx)
                                 .into_any_element()
                         })),
                 )
@@ -2085,6 +2556,10 @@ impl RemoteServerProjects {
         )
         .entry(state.add_new_server.clone());
 
+        if has_open_project {
+            modal_section = modal_section.entry(state.add_new_devcontainer.clone());
+        }
+
         if cfg!(target_os = "windows") {
             modal_section = modal_section.entry(state.add_new_wsl.clone());
         }
@@ -2297,6 +2772,9 @@ impl Render for RemoteServerProjects {
                 Mode::CreateRemoteServer(state) => self
                     .render_create_remote_server(state, window, cx)
                     .into_any_element(),
+                Mode::CreateRemoteDevContainer(state) => self
+                    .render_create_dev_container(state, window, cx)
+                    .into_any_element(),
                 Mode::EditNickname(state) => self
                     .render_edit_nickname(state, window, cx)
                     .into_any_element(),

crates/remote/src/remote.rs 🔗

@@ -10,5 +10,6 @@ pub use remote_client::{
     ConnectionIdentifier, ConnectionState, RemoteClient, RemoteClientDelegate, RemoteClientEvent,
     RemoteConnection, RemoteConnectionOptions, RemotePlatform, connect,
 };
+pub use transport::docker::DockerConnectionOptions;
 pub use transport::ssh::{SshConnectionOptions, SshPortForwardOption};
 pub use transport::wsl::WslConnectionOptions;

crates/remote/src/remote_client.rs 🔗

@@ -3,6 +3,7 @@ use crate::{
     protocol::MessageId,
     proxy::ProxyLaunchError,
     transport::{
+        docker::{DockerConnectionOptions, DockerExecConnection},
         ssh::SshRemoteConnection,
         wsl::{WslConnectionOptions, WslRemoteConnection},
     },
@@ -1042,6 +1043,11 @@ impl ConnectionPool {
                                 .await
                                 .map(|connection| Arc::new(connection) as Arc<dyn RemoteConnection>)
                         }
+                        RemoteConnectionOptions::Docker(opts) => {
+                            DockerExecConnection::new(opts, delegate, cx)
+                                .await
+                                .map(|connection| Arc::new(connection) as Arc<dyn RemoteConnection>)
+                        }
                     };
 
                     cx.update_global(|pool: &mut Self, _| {
@@ -1077,6 +1083,7 @@ impl ConnectionPool {
 pub enum RemoteConnectionOptions {
     Ssh(SshConnectionOptions),
     Wsl(WslConnectionOptions),
+    Docker(DockerConnectionOptions),
 }
 
 impl RemoteConnectionOptions {
@@ -1084,6 +1091,7 @@ impl RemoteConnectionOptions {
         match self {
             RemoteConnectionOptions::Ssh(opts) => opts.host.clone(),
             RemoteConnectionOptions::Wsl(opts) => opts.distro_name.clone(),
+            RemoteConnectionOptions::Docker(opts) => opts.name.clone(),
         }
     }
 }

crates/remote/src/transport.rs 🔗

@@ -12,6 +12,7 @@ use gpui::{AppContext as _, AsyncApp, Task};
 use rpc::proto::Envelope;
 use smol::process::Child;
 
+pub mod docker;
 pub mod ssh;
 pub mod wsl;
 
@@ -64,15 +65,15 @@ fn parse_shell(output: &str, fallback_shell: &str) -> String {
 }
 
 fn handle_rpc_messages_over_child_process_stdio(
-    mut ssh_proxy_process: Child,
+    mut remote_proxy_process: Child,
     incoming_tx: UnboundedSender<Envelope>,
     mut outgoing_rx: UnboundedReceiver<Envelope>,
     mut connection_activity_tx: Sender<()>,
     cx: &AsyncApp,
 ) -> Task<Result<i32>> {
-    let mut child_stderr = ssh_proxy_process.stderr.take().unwrap();
-    let mut child_stdout = ssh_proxy_process.stdout.take().unwrap();
-    let mut child_stdin = ssh_proxy_process.stdin.take().unwrap();
+    let mut child_stderr = remote_proxy_process.stderr.take().unwrap();
+    let mut child_stdout = remote_proxy_process.stdout.take().unwrap();
+    let mut child_stdin = remote_proxy_process.stdin.take().unwrap();
 
     let mut stdin_buffer = Vec::new();
     let mut stdout_buffer = Vec::new();
@@ -156,7 +157,7 @@ fn handle_rpc_messages_over_child_process_stdio(
                 result.context("stderr")
             }
         };
-        let status = ssh_proxy_process.status().await?.code().unwrap_or(1);
+        let status = remote_proxy_process.status().await?.code().unwrap_or(1);
         match result {
             Ok(_) => Ok(status),
             Err(error) => Err(error),

crates/remote/src/transport/docker.rs 🔗

@@ -0,0 +1,757 @@
+use anyhow::Context;
+use anyhow::Result;
+use anyhow::anyhow;
+use async_trait::async_trait;
+use collections::HashMap;
+use parking_lot::Mutex;
+use release_channel::{AppCommitSha, AppVersion, ReleaseChannel};
+use semver::Version as SemanticVersion;
+use std::time::Instant;
+use std::{
+    path::{Path, PathBuf},
+    process::Stdio,
+    sync::Arc,
+};
+use util::ResultExt;
+use util::shell::ShellKind;
+use util::{
+    paths::{PathStyle, RemotePathBuf},
+    rel_path::RelPath,
+};
+
+use futures::channel::mpsc::{Sender, UnboundedReceiver, UnboundedSender};
+use gpui::{App, AppContext, AsyncApp, Task};
+use rpc::proto::Envelope;
+
+use crate::{
+    RemoteClientDelegate, RemoteConnection, RemoteConnectionOptions, RemotePlatform,
+    remote_client::CommandTemplate,
+};
+
+#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
+pub struct DockerConnectionOptions {
+    pub name: String,
+    pub container_id: String,
+    pub upload_binary_over_docker_exec: bool,
+}
+
+pub(crate) struct DockerExecConnection {
+    proxy_process: Mutex<Option<u32>>,
+    remote_dir_for_server: String,
+    remote_binary_relpath: Option<Arc<RelPath>>,
+    connection_options: DockerConnectionOptions,
+    remote_platform: Option<RemotePlatform>,
+    path_style: Option<PathStyle>,
+    shell: Option<String>,
+}
+
+impl DockerExecConnection {
+    pub async fn new(
+        connection_options: DockerConnectionOptions,
+        delegate: Arc<dyn RemoteClientDelegate>,
+        cx: &mut AsyncApp,
+    ) -> Result<Self> {
+        let mut this = Self {
+            proxy_process: Mutex::new(None),
+            remote_dir_for_server: "/".to_string(),
+            remote_binary_relpath: None,
+            connection_options,
+            remote_platform: None,
+            path_style: None,
+            shell: None,
+        };
+        let (release_channel, version, commit) = cx.update(|cx| {
+            (
+                ReleaseChannel::global(cx),
+                AppVersion::global(cx),
+                AppCommitSha::try_global(cx),
+            )
+        })?;
+        let remote_platform = this.check_remote_platform().await?;
+
+        this.path_style = match remote_platform.os {
+            "windows" => Some(PathStyle::Windows),
+            _ => Some(PathStyle::Posix),
+        };
+
+        this.remote_platform = Some(remote_platform);
+
+        this.shell = Some(this.discover_shell().await);
+
+        this.remote_dir_for_server = this.docker_user_home_dir().await?.trim().to_string();
+
+        this.remote_binary_relpath = Some(
+            this.ensure_server_binary(
+                &delegate,
+                release_channel,
+                version,
+                &this.remote_dir_for_server,
+                commit,
+                cx,
+            )
+            .await?,
+        );
+
+        Ok(this)
+    }
+
+    async fn discover_shell(&self) -> String {
+        let default_shell = "sh";
+        match self
+            .run_docker_exec("sh", None, &Default::default(), &["-c", "echo $SHELL"])
+            .await
+        {
+            Ok(shell) => match shell.trim() {
+                "" => {
+                    log::error!("$SHELL is not set, falling back to {default_shell}");
+                    default_shell.to_owned()
+                }
+                shell => shell.to_owned(),
+            },
+            Err(e) => {
+                log::error!("Failed to get shell: {e}");
+                default_shell.to_owned()
+            }
+        }
+    }
+
+    async fn check_remote_platform(&self) -> Result<RemotePlatform> {
+        let uname = self
+            .run_docker_exec("uname", None, &Default::default(), &["-sm"])
+            .await?;
+        let Some((os, arch)) = uname.split_once(" ") else {
+            anyhow::bail!("unknown uname: {uname:?}")
+        };
+
+        let os = match os.trim() {
+            "Darwin" => "macos",
+            "Linux" => "linux",
+            _ => anyhow::bail!(
+                "Prebuilt remote servers are not yet available for {os:?}. See https://zed.dev/docs/remote-development"
+            ),
+        };
+        // exclude armv5,6,7 as they are 32-bit.
+        let arch = if arch.starts_with("armv8")
+            || arch.starts_with("armv9")
+            || arch.starts_with("arm64")
+            || arch.starts_with("aarch64")
+        {
+            "aarch64"
+        } else if arch.starts_with("x86") {
+            "x86_64"
+        } else {
+            anyhow::bail!(
+                "Prebuilt remote servers are not yet available for {arch:?}. See https://zed.dev/docs/remote-development"
+            )
+        };
+
+        Ok(RemotePlatform { os, arch })
+    }
+
+    async fn ensure_server_binary(
+        &self,
+        delegate: &Arc<dyn RemoteClientDelegate>,
+        release_channel: ReleaseChannel,
+        version: SemanticVersion,
+        remote_dir_for_server: &str,
+        commit: Option<AppCommitSha>,
+        cx: &mut AsyncApp,
+    ) -> Result<Arc<RelPath>> {
+        let remote_platform = if self.remote_platform.is_some() {
+            self.remote_platform.unwrap()
+        } else {
+            anyhow::bail!("No remote platform defined; cannot proceed.")
+        };
+
+        let version_str = match release_channel {
+            ReleaseChannel::Nightly => {
+                let commit = commit.map(|s| s.full()).unwrap_or_default();
+                format!("{}-{}", version, commit)
+            }
+            ReleaseChannel::Dev => "build".to_string(),
+            _ => version.to_string(),
+        };
+        let binary_name = format!(
+            "zed-remote-server-{}-{}",
+            release_channel.dev_name(),
+            version_str
+        );
+        let dst_path =
+            paths::remote_server_dir_relative().join(RelPath::unix(&binary_name).unwrap());
+
+        #[cfg(debug_assertions)]
+        if let Some(remote_server_path) =
+            super::build_remote_server_from_source(&remote_platform, delegate.as_ref(), cx).await?
+        {
+            let tmp_path = paths::remote_server_dir_relative().join(
+                RelPath::unix(&format!(
+                    "download-{}-{}",
+                    std::process::id(),
+                    remote_server_path.file_name().unwrap().to_string_lossy()
+                ))
+                .unwrap(),
+            );
+            self.upload_local_server_binary(
+                &remote_server_path,
+                &tmp_path,
+                &remote_dir_for_server,
+                delegate,
+                cx,
+            )
+            .await?;
+            self.extract_server_binary(&dst_path, &tmp_path, &remote_dir_for_server, delegate, cx)
+                .await?;
+            return Ok(dst_path);
+        }
+
+        if self
+            .run_docker_exec(
+                &dst_path.display(self.path_style()),
+                Some(&remote_dir_for_server),
+                &Default::default(),
+                &["version"],
+            )
+            .await
+            .is_ok()
+        {
+            return Ok(dst_path);
+        }
+
+        let wanted_version = cx.update(|cx| match release_channel {
+            ReleaseChannel::Nightly => Ok(None),
+            ReleaseChannel::Dev => {
+                anyhow::bail!(
+                    "ZED_BUILD_REMOTE_SERVER is not set and no remote server exists at ({:?})",
+                    dst_path
+                )
+            }
+            _ => Ok(Some(AppVersion::global(cx))),
+        })??;
+
+        let tmp_path_gz = paths::remote_server_dir_relative().join(
+            RelPath::unix(&format!(
+                "{}-download-{}.gz",
+                binary_name,
+                std::process::id()
+            ))
+            .unwrap(),
+        );
+        if !self.connection_options.upload_binary_over_docker_exec
+            && let Some(url) = delegate
+                .get_download_url(remote_platform, release_channel, wanted_version.clone(), cx)
+                .await?
+        {
+            match self
+                .download_binary_on_server(&url, &tmp_path_gz, &remote_dir_for_server, delegate, cx)
+                .await
+            {
+                Ok(_) => {
+                    self.extract_server_binary(
+                        &dst_path,
+                        &tmp_path_gz,
+                        &remote_dir_for_server,
+                        delegate,
+                        cx,
+                    )
+                    .await
+                    .context("extracting server binary")?;
+                    return Ok(dst_path);
+                }
+                Err(e) => {
+                    log::error!(
+                        "Failed to download binary on server, attempting to download locally and then upload it the server: {e:#}",
+                    )
+                }
+            }
+        }
+
+        let src_path = delegate
+            .download_server_binary_locally(remote_platform, release_channel, wanted_version, cx)
+            .await
+            .context("downloading server binary locally")?;
+        self.upload_local_server_binary(
+            &src_path,
+            &tmp_path_gz,
+            &remote_dir_for_server,
+            delegate,
+            cx,
+        )
+        .await
+        .context("uploading server binary")?;
+        self.extract_server_binary(
+            &dst_path,
+            &tmp_path_gz,
+            &remote_dir_for_server,
+            delegate,
+            cx,
+        )
+        .await
+        .context("extracting server binary")?;
+        Ok(dst_path)
+    }
+
+    async fn docker_user_home_dir(&self) -> Result<String> {
+        let inner_program = self.shell();
+        self.run_docker_exec(
+            &inner_program,
+            None,
+            &Default::default(),
+            &["-c", "echo $HOME"],
+        )
+        .await
+    }
+
+    async fn extract_server_binary(
+        &self,
+        dst_path: &RelPath,
+        tmp_path: &RelPath,
+        remote_dir_for_server: &str,
+        delegate: &Arc<dyn RemoteClientDelegate>,
+        cx: &mut AsyncApp,
+    ) -> Result<()> {
+        delegate.set_status(Some("Extracting remote development server"), cx);
+        let server_mode = 0o755;
+
+        let shell_kind = ShellKind::Posix;
+        let orig_tmp_path = tmp_path.display(self.path_style());
+        let server_mode = format!("{:o}", server_mode);
+        let server_mode = shell_kind
+            .try_quote(&server_mode)
+            .context("shell quoting")?;
+        let dst_path = dst_path.display(self.path_style());
+        let dst_path = shell_kind.try_quote(&dst_path).context("shell quoting")?;
+        let script = if let Some(tmp_path) = orig_tmp_path.strip_suffix(".gz") {
+            let orig_tmp_path = shell_kind
+                .try_quote(&orig_tmp_path)
+                .context("shell quoting")?;
+            let tmp_path = shell_kind.try_quote(&tmp_path).context("shell quoting")?;
+            format!(
+                "gunzip -f {orig_tmp_path} && chmod {server_mode} {tmp_path} && mv {tmp_path} {dst_path}",
+            )
+        } else {
+            let orig_tmp_path = shell_kind
+                .try_quote(&orig_tmp_path)
+                .context("shell quoting")?;
+            format!("chmod {server_mode} {orig_tmp_path} && mv {orig_tmp_path} {dst_path}",)
+        };
+        let args = shell_kind.args_for_shell(false, script.to_string());
+        self.run_docker_exec(
+            "sh",
+            Some(&remote_dir_for_server),
+            &Default::default(),
+            &args,
+        )
+        .await
+        .log_err();
+        Ok(())
+    }
+
+    async fn upload_local_server_binary(
+        &self,
+        src_path: &Path,
+        tmp_path_gz: &RelPath,
+        remote_dir_for_server: &str,
+        delegate: &Arc<dyn RemoteClientDelegate>,
+        cx: &mut AsyncApp,
+    ) -> Result<()> {
+        if let Some(parent) = tmp_path_gz.parent() {
+            self.run_docker_exec(
+                "mkdir",
+                Some(remote_dir_for_server),
+                &Default::default(),
+                &["-p", parent.display(self.path_style()).as_ref()],
+            )
+            .await?;
+        }
+
+        let src_stat = smol::fs::metadata(&src_path).await?;
+        let size = src_stat.len();
+
+        let t0 = Instant::now();
+        delegate.set_status(Some("Uploading remote development server"), cx);
+        log::info!(
+            "uploading remote development server to {:?} ({}kb)",
+            tmp_path_gz,
+            size / 1024
+        );
+        self.upload_file(src_path, tmp_path_gz, remote_dir_for_server)
+            .await
+            .context("failed to upload server binary")?;
+        log::info!("uploaded remote development server in {:?}", t0.elapsed());
+        Ok(())
+    }
+
+    async fn upload_file(
+        &self,
+        src_path: &Path,
+        dest_path: &RelPath,
+        remote_dir_for_server: &str,
+    ) -> Result<()> {
+        log::debug!("uploading file {:?} to {:?}", src_path, dest_path);
+
+        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");
+        command.arg("cp");
+        command.arg("-a");
+        command.arg(&src_path_display);
+        command.arg(format!(
+            "{}:{}/{}",
+            &self.connection_options.container_id, remote_dir_for_server, dest_path_str
+        ));
+
+        let output = command.output().await?;
+
+        if output.status.success() {
+            return Ok(());
+        }
+
+        let stderr = String::from_utf8_lossy(&output.stderr);
+        log::debug!(
+            "failed to upload file via docker cp {src_path_display} -> {dest_path_str}: {stderr}",
+        );
+        anyhow::bail!(
+            "failed to upload file via docker cp {} -> {}: {}",
+            src_path_display,
+            dest_path_str,
+            stderr,
+        );
+    }
+
+    async fn run_docker_command(
+        &self,
+        subcommand: &str,
+        args: &[impl AsRef<str>],
+    ) -> Result<String> {
+        let mut command = util::command::new_smol_command("docker");
+        command.arg(subcommand);
+        for arg in args {
+            command.arg(arg.as_ref());
+        }
+        let output = command.output().await?;
+        anyhow::ensure!(
+            output.status.success(),
+            "failed to run command {command:?}: {}",
+            String::from_utf8_lossy(&output.stderr)
+        );
+        Ok(String::from_utf8_lossy(&output.stdout).to_string())
+    }
+
+    async fn run_docker_exec(
+        &self,
+        inner_program: &str,
+        working_directory: Option<&str>,
+        env: &HashMap<String, String>,
+        program_args: &[impl AsRef<str>],
+    ) -> Result<String> {
+        let mut args = match working_directory {
+            Some(dir) => vec!["-w".to_string(), dir.to_string()],
+            None => vec![],
+        };
+
+        for (k, v) in env.iter() {
+            args.push("-e".to_string());
+            let env_declaration = format!("{}={}", k, v);
+            args.push(env_declaration);
+        }
+
+        args.push(self.connection_options.container_id.clone());
+        args.push(inner_program.to_string());
+
+        for arg in program_args {
+            args.push(arg.as_ref().to_owned());
+        }
+        self.run_docker_command("exec", args.as_ref()).await
+    }
+
+    async fn download_binary_on_server(
+        &self,
+        url: &str,
+        tmp_path_gz: &RelPath,
+        remote_dir_for_server: &str,
+        delegate: &Arc<dyn RemoteClientDelegate>,
+        cx: &mut AsyncApp,
+    ) -> Result<()> {
+        if let Some(parent) = tmp_path_gz.parent() {
+            self.run_docker_exec(
+                "mkdir",
+                Some(remote_dir_for_server),
+                &Default::default(),
+                &["-p", parent.display(self.path_style()).as_ref()],
+            )
+            .await?;
+        }
+
+        delegate.set_status(Some("Downloading remote development server on host"), cx);
+
+        match self
+            .run_docker_exec(
+                "curl",
+                Some(remote_dir_for_server),
+                &Default::default(),
+                &[
+                    "-f",
+                    "-L",
+                    url,
+                    "-o",
+                    &tmp_path_gz.display(self.path_style()),
+                ],
+            )
+            .await
+        {
+            Ok(_) => {}
+            Err(e) => {
+                if self
+                    .run_docker_exec("which", None, &Default::default(), &["curl"])
+                    .await
+                    .is_ok()
+                {
+                    return Err(e);
+                }
+
+                log::info!("curl is not available, trying wget");
+                match self
+                    .run_docker_exec(
+                        "wget",
+                        Some(remote_dir_for_server),
+                        &Default::default(),
+                        &[url, "-O", &tmp_path_gz.display(self.path_style())],
+                    )
+                    .await
+                {
+                    Ok(_) => {}
+                    Err(e) => {
+                        if self
+                            .run_docker_exec("which", None, &Default::default(), &["wget"])
+                            .await
+                            .is_ok()
+                        {
+                            return Err(e);
+                        } else {
+                            anyhow::bail!("Neither curl nor wget is available");
+                        }
+                    }
+                }
+            }
+        }
+        Ok(())
+    }
+
+    fn kill_inner(&self) -> Result<()> {
+        if let Some(pid) = self.proxy_process.lock().take() {
+            if let Ok(_) = util::command::new_smol_command("kill")
+                .arg(pid.to_string())
+                .spawn()
+            {
+                Ok(())
+            } else {
+                Err(anyhow::anyhow!("Failed to kill process"))
+            }
+        } else {
+            Ok(())
+        }
+    }
+}
+
+#[async_trait(?Send)]
+impl RemoteConnection for DockerExecConnection {
+    fn has_wsl_interop(&self) -> bool {
+        false
+    }
+    fn start_proxy(
+        &self,
+        unique_identifier: String,
+        reconnect: bool,
+        incoming_tx: UnboundedSender<Envelope>,
+        outgoing_rx: UnboundedReceiver<Envelope>,
+        connection_activity_tx: Sender<()>,
+        delegate: Arc<dyn RemoteClientDelegate>,
+        cx: &mut AsyncApp,
+    ) -> Task<Result<i32>> {
+        // We'll try connecting anew every time we open a devcontainer, so proactively try to kill any old connections.
+        if !self.has_been_killed() {
+            if let Err(e) = self.kill_inner() {
+                return Task::ready(Err(e));
+            };
+        }
+
+        delegate.set_status(Some("Starting proxy"), cx);
+
+        let Some(remote_binary_relpath) = self.remote_binary_relpath.clone() else {
+            return Task::ready(Err(anyhow!("Remote binary path not set")));
+        };
+
+        let mut docker_args = vec![
+            "exec".to_string(),
+            "-w".to_string(),
+            self.remote_dir_for_server.clone(),
+            "-i".to_string(),
+            self.connection_options.container_id.to_string(),
+        ];
+        for env_var in ["RUST_LOG", "RUST_BACKTRACE", "ZED_GENERATE_MINIDUMPS"] {
+            if let Some(value) = std::env::var(env_var).ok() {
+                docker_args.push("-e".to_string());
+                docker_args.push(format!("{}='{}'", env_var, value));
+            }
+        }
+        let val = remote_binary_relpath
+            .display(self.path_style())
+            .into_owned();
+        docker_args.push(val);
+        docker_args.push("proxy".to_string());
+        docker_args.push("--identifier".to_string());
+        docker_args.push(unique_identifier);
+        if reconnect {
+            docker_args.push("--reconnect".to_string());
+        }
+        let mut command = util::command::new_smol_command("docker");
+        command
+            .kill_on_drop(true)
+            .stdin(Stdio::piped())
+            .stdout(Stdio::piped())
+            .stderr(Stdio::piped())
+            .args(docker_args);
+
+        let Ok(child) = command.spawn() else {
+            return Task::ready(Err(anyhow::anyhow!(
+                "Failed to start remote server process"
+            )));
+        };
+
+        let mut proxy_process = self.proxy_process.lock();
+        *proxy_process = Some(child.id());
+
+        super::handle_rpc_messages_over_child_process_stdio(
+            child,
+            incoming_tx,
+            outgoing_rx,
+            connection_activity_tx,
+            cx,
+        )
+    }
+
+    fn upload_directory(
+        &self,
+        src_path: PathBuf,
+        dest_path: RemotePathBuf,
+        cx: &App,
+    ) -> Task<Result<()>> {
+        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");
+        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);
+        command.arg(format!(
+            "{}:{}",
+            self.connection_options.container_id, dest_path_str
+        ));
+
+        cx.background_spawn(async move {
+            let output = command.output().await?;
+
+            if output.status.success() {
+                Ok(())
+            } else {
+                Err(anyhow::anyhow!("Failed to upload directory"))
+            }
+        })
+    }
+
+    async fn kill(&self) -> Result<()> {
+        self.kill_inner()
+    }
+
+    fn has_been_killed(&self) -> bool {
+        self.proxy_process.lock().is_none()
+    }
+
+    fn build_command(
+        &self,
+        program: Option<String>,
+        args: &[String],
+        env: &HashMap<String, String>,
+        working_dir: Option<String>,
+        _port_forward: Option<(u16, String, u16)>,
+    ) -> Result<CommandTemplate> {
+        let mut parsed_working_dir = None;
+
+        let path_style = self.path_style();
+
+        if let Some(working_dir) = working_dir {
+            let working_dir = RemotePathBuf::new(working_dir, path_style).to_string();
+
+            const TILDE_PREFIX: &'static str = "~/";
+            if working_dir.starts_with(TILDE_PREFIX) {
+                let working_dir = working_dir.trim_start_matches("~").trim_start_matches("/");
+                parsed_working_dir = Some(format!("$HOME/{working_dir}"));
+            } else {
+                parsed_working_dir = Some(working_dir);
+            }
+        }
+
+        let mut inner_program = Vec::new();
+
+        if let Some(program) = program {
+            inner_program.push(program);
+            for arg in args {
+                inner_program.push(arg.clone());
+            }
+        } else {
+            inner_program.push(self.shell());
+            inner_program.push("-l".to_string());
+        };
+
+        let mut docker_args = vec!["exec".to_string()];
+
+        if let Some(parsed_working_dir) = parsed_working_dir {
+            docker_args.push("-w".to_string());
+            docker_args.push(parsed_working_dir);
+        }
+
+        for (k, v) in env.iter() {
+            docker_args.push("-e".to_string());
+            docker_args.push(format!("{}={}", k, v));
+        }
+
+        docker_args.push("-it".to_string());
+        docker_args.push(self.connection_options.container_id.to_string());
+
+        docker_args.append(&mut inner_program);
+
+        Ok(CommandTemplate {
+            program: "docker".to_string(),
+            args: docker_args,
+            // Docker-exec pipes in environment via the "-e" argument
+            env: Default::default(),
+        })
+    }
+
+    fn build_forward_ports_command(
+        &self,
+        _forwards: Vec<(u16, String, u16)>,
+    ) -> Result<CommandTemplate> {
+        Err(anyhow::anyhow!("Not currently supported for docker_exec"))
+    }
+
+    fn connection_options(&self) -> RemoteConnectionOptions {
+        RemoteConnectionOptions::Docker(self.connection_options.clone())
+    }
+
+    fn path_style(&self) -> PathStyle {
+        self.path_style.unwrap_or(PathStyle::Posix)
+    }
+
+    fn shell(&self) -> String {
+        match &self.shell {
+            Some(shell) => shell.clone(),
+            None => self.default_system_shell(),
+        }
+    }
+
+    fn default_system_shell(&self) -> String {
+        String::from("/bin/sh")
+    }
+}

crates/settings/src/settings_content.rs 🔗

@@ -889,9 +889,19 @@ pub enum ImageFileSizeUnit {
 pub struct RemoteSettingsContent {
     pub ssh_connections: Option<Vec<SshConnection>>,
     pub wsl_connections: Option<Vec<WslConnection>>,
+    pub dev_container_connections: Option<Vec<DevContainerConnection>>,
     pub read_ssh_config: Option<bool>,
 }
 
+#[with_fallible_options]
+#[derive(
+    Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, MergeFrom, Hash,
+)]
+pub struct DevContainerConnection {
+    pub name: SharedString,
+    pub container_id: SharedString,
+}
+
 #[with_fallible_options]
 #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)]
 pub struct SshConnection {
@@ -901,7 +911,7 @@ pub struct SshConnection {
     #[serde(default)]
     pub args: Vec<String>,
     #[serde(default)]
-    pub projects: collections::BTreeSet<SshProject>,
+    pub projects: collections::BTreeSet<RemoteProject>,
     /// Name to use for this server in UI.
     pub nickname: Option<String>,
     // By default Zed will download the binary to the host directly.
@@ -918,14 +928,14 @@ pub struct WslConnection {
     pub distro_name: SharedString,
     pub user: Option<String>,
     #[serde(default)]
-    pub projects: BTreeSet<SshProject>,
+    pub projects: BTreeSet<RemoteProject>,
 }
 
 #[with_fallible_options]
 #[derive(
     Clone, Debug, Default, Serialize, PartialEq, Eq, PartialOrd, Ord, Deserialize, JsonSchema,
 )]
-pub struct SshProject {
+pub struct RemoteProject {
     pub paths: Vec<String>,
 }
 

crates/title_bar/src/title_bar.rs 🔗

@@ -323,12 +323,18 @@ impl TitleBar {
         let options = self.project.read(cx).remote_connection_options(cx)?;
         let host: SharedString = options.display_name().into();
 
-        let (nickname, icon) = match options {
-            RemoteConnectionOptions::Ssh(options) => {
-                (options.nickname.map(|nick| nick.into()), IconName::Server)
+        let (nickname, tooltip_title, icon) = match options {
+            RemoteConnectionOptions::Ssh(options) => (
+                options.nickname.map(|nick| nick.into()),
+                "Remote Project",
+                IconName::Server,
+            ),
+            RemoteConnectionOptions::Wsl(_) => (None, "Remote Project", IconName::Linux),
+            RemoteConnectionOptions::Docker(_dev_container_connection) => {
+                (None, "Dev Container", IconName::Box)
             }
-            RemoteConnectionOptions::Wsl(_) => (None, IconName::Linux),
         };
+
         let nickname = nickname.unwrap_or_else(|| host.clone());
 
         let (indicator_color, meta) = match self.project.read(cx).remote_connection_state(cx)? {
@@ -375,7 +381,7 @@ impl TitleBar {
                 )
                 .tooltip(move |_window, cx| {
                     Tooltip::with_meta(
-                        "Remote Project",
+                        tooltip_title,
                         Some(&OpenRemote {
                             from_existing_connection: false,
                             create_new_window: false,

crates/workspace/src/persistence.rs 🔗

@@ -20,7 +20,9 @@ use project::debugger::breakpoint_store::{BreakpointState, SourceBreakpoint};
 
 use language::{LanguageName, Toolchain, ToolchainScope};
 use project::WorktreeId;
-use remote::{RemoteConnectionOptions, SshConnectionOptions, WslConnectionOptions};
+use remote::{
+    DockerConnectionOptions, RemoteConnectionOptions, SshConnectionOptions, WslConnectionOptions,
+};
 use sqlez::{
     bindable::{Bind, Column, StaticColumnCount},
     statement::Statement,
@@ -702,6 +704,10 @@ impl Domain for WorkspaceDb {
         sql!(
             DROP TABLE ssh_connections;
         ),
+        sql!(
+            ALTER TABLE remote_connections ADD COLUMN name TEXT;
+            ALTER TABLE remote_connections ADD COLUMN container_id TEXT;
+        ),
     ];
 
     // Allow recovering from bad migration that was initially shipped to nightly
@@ -728,9 +734,9 @@ impl WorkspaceDb {
     pub(crate) fn remote_workspace_for_roots<P: AsRef<Path>>(
         &self,
         worktree_roots: &[P],
-        ssh_project_id: RemoteConnectionId,
+        remote_project_id: RemoteConnectionId,
     ) -> Option<SerializedWorkspace> {
-        self.workspace_for_roots_internal(worktree_roots, Some(ssh_project_id))
+        self.workspace_for_roots_internal(worktree_roots, Some(remote_project_id))
     }
 
     pub(crate) fn workspace_for_roots_internal<P: AsRef<Path>>(
@@ -806,9 +812,20 @@ impl WorkspaceDb {
             order: paths_order,
         });
 
+        let remote_connection_options = if let Some(remote_connection_id) = remote_connection_id {
+            self.remote_connection(remote_connection_id)
+                .context("Get remote connection")
+                .log_err()
+        } else {
+            None
+        };
+
         Some(SerializedWorkspace {
             id: workspace_id,
-            location: SerializedWorkspaceLocation::Local,
+            location: match remote_connection_options {
+                Some(options) => SerializedWorkspaceLocation::Remote(options),
+                None => SerializedWorkspaceLocation::Local,
+            },
             paths,
             center_group: self
                 .get_center_pane_group(workspace_id)
@@ -1110,10 +1127,12 @@ impl WorkspaceDb {
         options: RemoteConnectionOptions,
     ) -> Result<RemoteConnectionId> {
         let kind;
-        let user;
+        let mut user = None;
         let mut host = None;
         let mut port = None;
         let mut distro = None;
+        let mut name = None;
+        let mut container_id = None;
         match options {
             RemoteConnectionOptions::Ssh(options) => {
                 kind = RemoteConnectionKind::Ssh;
@@ -1126,8 +1145,22 @@ impl WorkspaceDb {
                 distro = Some(options.distro_name);
                 user = options.user;
             }
+            RemoteConnectionOptions::Docker(options) => {
+                kind = RemoteConnectionKind::Docker;
+                container_id = Some(options.container_id);
+                name = Some(options.name);
+            }
         }
-        Self::get_or_create_remote_connection_query(this, kind, host, port, user, distro)
+        Self::get_or_create_remote_connection_query(
+            this,
+            kind,
+            host,
+            port,
+            user,
+            distro,
+            name,
+            container_id,
+        )
     }
 
     fn get_or_create_remote_connection_query(
@@ -1137,6 +1170,8 @@ impl WorkspaceDb {
         port: Option<u16>,
         user: Option<String>,
         distro: Option<String>,
+        name: Option<String>,
+        container_id: Option<String>,
     ) -> Result<RemoteConnectionId> {
         if let Some(id) = this.select_row_bound(sql!(
             SELECT id
@@ -1146,7 +1181,9 @@ impl WorkspaceDb {
                 host IS ? AND
                 port IS ? AND
                 user IS ? AND
-                distro IS ?
+                distro IS ? AND
+                name IS ? AND
+                container_id IS ?
             LIMIT 1
         ))?((
             kind.serialize(),
@@ -1154,6 +1191,8 @@ impl WorkspaceDb {
             port,
             user.clone(),
             distro.clone(),
+            name.clone(),
+            container_id.clone(),
         ))? {
             Ok(RemoteConnectionId(id))
         } else {
@@ -1163,10 +1202,20 @@ impl WorkspaceDb {
                     host,
                     port,
                     user,
-                    distro
-                ) VALUES (?1, ?2, ?3, ?4, ?5)
+                    distro,
+                    name,
+                    container_id
+                ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
                 RETURNING id
-            ))?((kind.serialize(), host, port, user, distro))?
+            ))?((
+                kind.serialize(),
+                host,
+                port,
+                user,
+                distro,
+                name,
+                container_id,
+            ))?
             .context("failed to insert remote project")?;
             Ok(RemoteConnectionId(id))
         }
@@ -1249,15 +1298,23 @@ impl WorkspaceDb {
     fn remote_connections(&self) -> Result<HashMap<RemoteConnectionId, RemoteConnectionOptions>> {
         Ok(self.select(sql!(
             SELECT
-                id, kind, host, port, user, distro
+                id, kind, host, port, user, distro, container_id, name
             FROM
                 remote_connections
         ))?()?
         .into_iter()
-        .filter_map(|(id, kind, host, port, user, distro)| {
+        .filter_map(|(id, kind, host, port, user, distro, container_id, name)| {
             Some((
                 RemoteConnectionId(id),
-                Self::remote_connection_from_row(kind, host, port, user, distro)?,
+                Self::remote_connection_from_row(
+                    kind,
+                    host,
+                    port,
+                    user,
+                    distro,
+                    container_id,
+                    name,
+                )?,
             ))
         })
         .collect())
@@ -1267,13 +1324,13 @@ impl WorkspaceDb {
         &self,
         id: RemoteConnectionId,
     ) -> Result<RemoteConnectionOptions> {
-        let (kind, host, port, user, distro) = self.select_row_bound(sql!(
-            SELECT kind, host, port, user, distro
+        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)
+        Self::remote_connection_from_row(kind, host, port, user, distro, container_id, name)
             .context("invalid remote_connection row")
     }
 
@@ -1283,6 +1340,8 @@ impl WorkspaceDb {
         port: Option<u16>,
         user: Option<String>,
         distro: Option<String>,
+        container_id: Option<String>,
+        name: Option<String>,
     ) -> Option<RemoteConnectionOptions> {
         match RemoteConnectionKind::deserialize(&kind)? {
             RemoteConnectionKind::Wsl => Some(RemoteConnectionOptions::Wsl(WslConnectionOptions {
@@ -1295,6 +1354,13 @@ impl WorkspaceDb {
                 username: user,
                 ..Default::default()
             })),
+            RemoteConnectionKind::Docker => {
+                Some(RemoteConnectionOptions::Docker(DockerConnectionOptions {
+                    container_id: container_id?,
+                    name: name?,
+                    upload_binary_over_docker_exec: false,
+                }))
+            }
         }
     }
 

crates/workspace/src/persistence/model.rs 🔗

@@ -32,6 +32,7 @@ pub(crate) struct RemoteConnectionId(pub u64);
 pub(crate) enum RemoteConnectionKind {
     Ssh,
     Wsl,
+    Docker,
 }
 
 #[derive(Debug, PartialEq, Clone)]
@@ -75,6 +76,7 @@ impl RemoteConnectionKind {
         match self {
             RemoteConnectionKind::Ssh => "ssh",
             RemoteConnectionKind::Wsl => "wsl",
+            RemoteConnectionKind::Docker => "docker",
         }
     }
 
@@ -82,6 +84,7 @@ impl RemoteConnectionKind {
         match text {
             "ssh" => Some(Self::Ssh),
             "wsl" => Some(Self::Wsl),
+            "docker" => Some(Self::Docker),
             _ => None,
         }
     }

crates/workspace/src/workspace.rs 🔗

@@ -7780,7 +7780,7 @@ pub fn open_remote_project_with_new_connection(
 ) -> Task<Result<Vec<Option<Box<dyn ItemHandle>>>>> {
     cx.spawn(async move |cx| {
         let (workspace_id, serialized_workspace) =
-            serialize_remote_project(remote_connection.connection_options(), paths.clone(), cx)
+            deserialize_remote_project(remote_connection.connection_options(), paths.clone(), cx)
                 .await?;
 
         let session = match cx
@@ -7834,7 +7834,7 @@ pub fn open_remote_project_with_existing_connection(
 ) -> Task<Result<Vec<Option<Box<dyn ItemHandle>>>>> {
     cx.spawn(async move |cx| {
         let (workspace_id, serialized_workspace) =
-            serialize_remote_project(connection_options.clone(), paths.clone(), cx).await?;
+            deserialize_remote_project(connection_options.clone(), paths.clone(), cx).await?;
 
         open_remote_project_inner(
             project,
@@ -7936,7 +7936,7 @@ async fn open_remote_project_inner(
     Ok(items.into_iter().map(|item| item?.ok()).collect())
 }
 
-fn serialize_remote_project(
+fn deserialize_remote_project(
     connection_options: RemoteConnectionOptions,
     paths: Vec<PathBuf>,
     cx: &AsyncApp,

crates/zed_actions/src/lib.rs 🔗

@@ -428,6 +428,12 @@ pub struct OpenRemote {
     pub create_new_window: bool,
 }
 
+/// Opens the dev container connection modal.
+#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
+#[action(namespace = projects)]
+#[serde(deny_unknown_fields)]
+pub struct OpenDevContainer;
+
 /// Where to spawn the task in the UI.
 #[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
 #[serde(rename_all = "snake_case")]