Introduce MVP Dev Containers support (#44442)

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>
This commit is contained in:
KyleBarton
2025-12-10 12:10:43 -08:00
committed by GitHub
co-authored by Danilo Leal Danilo Leal
parent a61bf33fb0
commit 3a84ec38ac
22 changed files with 1991 additions and 85 deletions
+82 -16
View File
@@ -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,
}))
}
}
}
@@ -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,
}
}
+3 -3
View File
@@ -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,