devcontainer: Support `.devcontainer.json` in project root (#48814)

Oliver Azevedo Barnes created

Closes #48683

Per the devcontainer spec, `.devcontainer.json` in the project root is
a valid config location. It is only used when no configurations are
found inside `.devcontainer/`.

Extract `find_configs_in_snapshot` for testability and add tests.

Release Notes:

- Added support for `.devcontainer.json` in project root

Change summary

Cargo.lock                                          |   3 
crates/dev_container/Cargo.toml                     |   7 
crates/dev_container/src/devcontainer_api.rs        | 437 ++++++++++++--
crates/dev_container/src/lib.rs                     |   3 
crates/recent_projects/src/dev_container_suggest.rs |  21 
5 files changed, 388 insertions(+), 83 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -4929,6 +4929,7 @@ checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
 name = "dev_container"
 version = "0.1.0"
 dependencies = [
+ "fs",
  "futures 0.3.31",
  "gpui",
  "http 1.3.1",
@@ -4938,6 +4939,7 @@ dependencies = [
  "node_runtime",
  "paths",
  "picker",
+ "project",
  "serde",
  "serde_json",
  "settings",
@@ -4945,6 +4947,7 @@ dependencies = [
  "ui",
  "util",
  "workspace",
+ "worktree",
 ]
 
 [[package]]

crates/dev_container/Cargo.toml 🔗

@@ -20,10 +20,17 @@ settings.workspace = true
 smol.workspace = true
 ui.workspace = true
 util.workspace = true
+worktree.workspace = true
 workspace.workspace = true
 
 [dev-dependencies]
+fs.workspace = true
 gpui = { workspace = true, features = ["test-support"] }
+project = { workspace = true, features = ["test-support"] }
+serde_json.workspace = true
+settings = { workspace = true, features = ["test-support"] }
+workspace = { workspace = true, features = ["test-support"] }
+worktree = { workspace = true, features = ["test-support"] }
 
 [lints]
 workspace = true

crates/dev_container/src/devcontainer_api.rs 🔗

@@ -12,6 +12,7 @@ use settings::{DevContainerConnection, Settings as _};
 use smol::{fs, process::Command};
 use util::rel_path::RelPath;
 use workspace::Workspace;
+use worktree::Snapshot;
 
 use crate::{DevContainerFeature, DevContainerSettings, DevContainerTemplate};
 
@@ -31,6 +32,13 @@ impl DevContainerConfig {
             config_path: PathBuf::from(".devcontainer/devcontainer.json"),
         }
     }
+
+    pub fn root_config() -> Self {
+        Self {
+            name: "root".to_string(),
+            config_path: PathBuf::from(".devcontainer.json"),
+        }
+    }
 }
 
 #[derive(Debug, Deserialize)]
@@ -153,11 +161,7 @@ fn use_podman(cx: &mut AsyncWindowContext) -> bool {
 
 /// Finds all available devcontainer configurations in the project.
 ///
-/// This function scans for:
-/// 1. `.devcontainer/devcontainer.json` (the default location)
-/// 2. `.devcontainer/<subfolder>/devcontainer.json` (named configurations)
-///
-/// Returns a list of found configurations, or an empty list if none are found.
+/// See [`find_configs_in_snapshot`] for the locations that are scanned.
 pub fn find_devcontainer_configs(cx: &mut AsyncWindowContext) -> Vec<DevContainerConfig> {
     let Some(workspace) = cx.window_handle().downcast::<Workspace>() else {
         log::debug!("find_devcontainer_configs: No workspace found");
@@ -177,82 +181,100 @@ pub fn find_devcontainer_configs(cx: &mut AsyncWindowContext) -> Vec<DevContaine
         };
 
         let worktree = worktree.read(cx);
-        let mut configs = Vec::new();
-
-        let devcontainer_path = RelPath::unix(".devcontainer").expect("valid path");
-
-        let Some(devcontainer_entry) = worktree.entry_for_path(devcontainer_path) else {
-            log::debug!("find_devcontainer_configs: .devcontainer directory not found in worktree");
-            return Vec::new();
-        };
+        find_configs_in_snapshot(worktree)
+    }) else {
+        log::debug!("find_devcontainer_configs: Failed to update workspace");
+        return Vec::new();
+    };
 
-        if !devcontainer_entry.is_dir() {
-            log::debug!("find_devcontainer_configs: .devcontainer is not a directory");
-            return Vec::new();
-        }
+    configs
+}
 
-        log::debug!("find_devcontainer_configs: Scanning .devcontainer directory");
-        let devcontainer_json_path =
-            RelPath::unix(".devcontainer/devcontainer.json").expect("valid path");
-        for entry in worktree.child_entries(devcontainer_path) {
-            log::debug!(
-                "find_devcontainer_configs: Found entry: {:?}, is_file: {}, is_dir: {}",
-                entry.path.as_unix_str(),
-                entry.is_file(),
-                entry.is_dir()
-            );
+/// Scans a worktree snapshot for devcontainer configurations.
+///
+/// Scans for configurations in these locations:
+/// 1. `.devcontainer/devcontainer.json` (the default location)
+/// 2. `.devcontainer.json` in the project root
+/// 3. `.devcontainer/<subfolder>/devcontainer.json` (named configurations)
+///
+/// All found configurations are returned so the user can pick between them.
+pub fn find_configs_in_snapshot(snapshot: &Snapshot) -> Vec<DevContainerConfig> {
+    let mut configs = Vec::new();
+
+    let devcontainer_dir_path = RelPath::unix(".devcontainer").expect("valid path");
+
+    if let Some(devcontainer_entry) = snapshot.entry_for_path(devcontainer_dir_path) {
+        if devcontainer_entry.is_dir() {
+            log::debug!("find_configs_in_snapshot: Scanning .devcontainer directory");
+            let devcontainer_json_path =
+                RelPath::unix(".devcontainer/devcontainer.json").expect("valid path");
+            for entry in snapshot.child_entries(devcontainer_dir_path) {
+                log::debug!(
+                    "find_configs_in_snapshot: Found entry: {:?}, is_file: {}, is_dir: {}",
+                    entry.path.as_unix_str(),
+                    entry.is_file(),
+                    entry.is_dir()
+                );
 
-            if entry.is_file() && entry.path.as_ref() == devcontainer_json_path {
-                log::debug!("find_devcontainer_configs: Found default devcontainer.json");
-                configs.push(DevContainerConfig::default_config());
-            } else if entry.is_dir() {
-                let subfolder_name = entry
-                    .path
-                    .file_name()
-                    .map(|n| n.to_string())
-                    .unwrap_or_default();
-
-                let config_json_path = format!("{}/devcontainer.json", entry.path.as_unix_str());
-                if let Ok(rel_config_path) = RelPath::unix(&config_json_path) {
-                    if worktree.entry_for_path(rel_config_path).is_some() {
-                        log::debug!(
-                            "find_devcontainer_configs: Found config in subfolder: {}",
-                            subfolder_name
-                        );
-                        configs.push(DevContainerConfig {
-                            name: subfolder_name,
-                            config_path: PathBuf::from(&config_json_path),
-                        });
-                    } else {
-                        log::debug!(
-                            "find_devcontainer_configs: Subfolder {} has no devcontainer.json",
-                            subfolder_name
-                        );
+                if entry.is_file() && entry.path.as_ref() == devcontainer_json_path {
+                    log::debug!("find_configs_in_snapshot: Found default devcontainer.json");
+                    configs.push(DevContainerConfig::default_config());
+                } else if entry.is_dir() {
+                    let subfolder_name = entry
+                        .path
+                        .file_name()
+                        .map(|n| n.to_string())
+                        .unwrap_or_default();
+
+                    let config_json_path =
+                        format!("{}/devcontainer.json", entry.path.as_unix_str());
+                    if let Ok(rel_config_path) = RelPath::unix(&config_json_path) {
+                        if snapshot.entry_for_path(rel_config_path).is_some() {
+                            log::debug!(
+                                "find_configs_in_snapshot: Found config in subfolder: {}",
+                                subfolder_name
+                            );
+                            configs.push(DevContainerConfig {
+                                name: subfolder_name,
+                                config_path: PathBuf::from(&config_json_path),
+                            });
+                        } else {
+                            log::debug!(
+                                "find_configs_in_snapshot: Subfolder {} has no devcontainer.json",
+                                subfolder_name
+                            );
+                        }
                     }
                 }
             }
         }
+    }
 
-        log::info!(
-            "find_devcontainer_configs: Found {} configurations",
-            configs.len()
-        );
-
-        configs.sort_by(|a, b| {
-            if a.name == "default" {
-                std::cmp::Ordering::Less
-            } else if b.name == "default" {
-                std::cmp::Ordering::Greater
-            } else {
-                a.name.cmp(&b.name)
-            }
-        });
+    // Always include `.devcontainer.json` so the user can pick it from the UI
+    // even when `.devcontainer/devcontainer.json` also exists.
+    let root_config_path = RelPath::unix(".devcontainer.json").expect("valid path");
+    if snapshot
+        .entry_for_path(root_config_path)
+        .is_some_and(|entry| entry.is_file())
+    {
+        log::debug!("find_configs_in_snapshot: Found .devcontainer.json in project root");
+        configs.push(DevContainerConfig::root_config());
+    }
 
-        configs
-    }) else {
-        log::debug!("find_devcontainer_configs: Failed to update workspace");
-        return Vec::new();
-    };
+    log::info!(
+        "find_configs_in_snapshot: Found {} configurations",
+        configs.len()
+    );
+
+    configs.sort_by(|a, b| {
+        let a_is_primary = a.name == "default" || a.name == "root";
+        let b_is_primary = b.name == "default" || b.name == "root";
+        match (a_is_primary, b_is_primary) {
+            (true, false) => std::cmp::Ordering::Less,
+            (false, true) => std::cmp::Ordering::Greater,
+            _ => a.name.cmp(&b.name),
+        }
+    });
 
     configs
 }
@@ -701,7 +723,25 @@ fn template_features_to_json(features_selected: &HashSet<DevContainerFeature>) -
 
 #[cfg(test)]
 mod tests {
-    use crate::devcontainer_api::{DevContainerUp, parse_json_from_cli};
+    use std::path::PathBuf;
+
+    use fs::FakeFs;
+    use gpui::TestAppContext;
+    use project::Project;
+    use serde_json::json;
+    use settings::SettingsStore;
+    use util::path;
+
+    use crate::devcontainer_api::{
+        DevContainerConfig, DevContainerUp, find_configs_in_snapshot, parse_json_from_cli,
+    };
+
+    fn init_test(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            let settings_store = SettingsStore::test(cx);
+            cx.set_global(settings_store);
+        });
+    }
 
     #[test]
     fn should_parse_from_devcontainer_json() {
@@ -726,4 +766,255 @@ mod tests {
         assert_eq!(up.remote_user, "vscode");
         assert_eq!(up.remote_workspace_folder, "/workspaces/zed");
     }
+
+    #[gpui::test]
+    async fn test_find_configs_root_devcontainer_json(cx: &mut TestAppContext) {
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/project"),
+            json!({
+                ".devcontainer.json": "{}"
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
+        cx.run_until_parked();
+
+        let configs = project.read_with(cx, |project, cx| {
+            let worktree = project
+                .visible_worktrees(cx)
+                .next()
+                .expect("should have a worktree");
+            find_configs_in_snapshot(worktree.read(cx))
+        });
+
+        assert_eq!(configs.len(), 1);
+        assert_eq!(configs[0].name, "root");
+        assert_eq!(configs[0].config_path, PathBuf::from(".devcontainer.json"));
+    }
+
+    #[gpui::test]
+    async fn test_find_configs_default_devcontainer_dir(cx: &mut TestAppContext) {
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/project"),
+            json!({
+                ".devcontainer": {
+                    "devcontainer.json": "{}"
+                }
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
+        cx.run_until_parked();
+
+        let configs = project.read_with(cx, |project, cx| {
+            let worktree = project
+                .visible_worktrees(cx)
+                .next()
+                .expect("should have a worktree");
+            find_configs_in_snapshot(worktree.read(cx))
+        });
+
+        assert_eq!(configs.len(), 1);
+        assert_eq!(configs[0], DevContainerConfig::default_config());
+    }
+
+    #[gpui::test]
+    async fn test_find_configs_dir_and_root_both_included(cx: &mut TestAppContext) {
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/project"),
+            json!({
+                ".devcontainer.json": "{}",
+                ".devcontainer": {
+                    "devcontainer.json": "{}"
+                }
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
+        cx.run_until_parked();
+
+        let configs = project.read_with(cx, |project, cx| {
+            let worktree = project
+                .visible_worktrees(cx)
+                .next()
+                .expect("should have a worktree");
+            find_configs_in_snapshot(worktree.read(cx))
+        });
+
+        assert_eq!(configs.len(), 2);
+        assert_eq!(configs[0], DevContainerConfig::default_config());
+        assert_eq!(configs[1], DevContainerConfig::root_config());
+    }
+
+    #[gpui::test]
+    async fn test_find_configs_subfolder_configs(cx: &mut TestAppContext) {
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/project"),
+            json!({
+                ".devcontainer": {
+                    "rust": {
+                        "devcontainer.json": "{}"
+                    },
+                    "python": {
+                        "devcontainer.json": "{}"
+                    }
+                }
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
+        cx.run_until_parked();
+
+        let configs = project.read_with(cx, |project, cx| {
+            let worktree = project
+                .visible_worktrees(cx)
+                .next()
+                .expect("should have a worktree");
+            find_configs_in_snapshot(worktree.read(cx))
+        });
+
+        assert_eq!(configs.len(), 2);
+        let names: Vec<&str> = configs.iter().map(|c| c.name.as_str()).collect();
+        assert!(names.contains(&"python"));
+        assert!(names.contains(&"rust"));
+    }
+
+    #[gpui::test]
+    async fn test_find_configs_default_and_subfolder(cx: &mut TestAppContext) {
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/project"),
+            json!({
+                ".devcontainer": {
+                    "devcontainer.json": "{}",
+                    "gpu": {
+                        "devcontainer.json": "{}"
+                    }
+                }
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
+        cx.run_until_parked();
+
+        let configs = project.read_with(cx, |project, cx| {
+            let worktree = project
+                .visible_worktrees(cx)
+                .next()
+                .expect("should have a worktree");
+            find_configs_in_snapshot(worktree.read(cx))
+        });
+
+        assert_eq!(configs.len(), 2);
+        assert_eq!(configs[0].name, "default");
+        assert_eq!(configs[1].name, "gpu");
+    }
+
+    #[gpui::test]
+    async fn test_find_configs_no_devcontainer(cx: &mut TestAppContext) {
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/project"),
+            json!({
+                "src": {
+                    "main.rs": "fn main() {}"
+                }
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
+        cx.run_until_parked();
+
+        let configs = project.read_with(cx, |project, cx| {
+            let worktree = project
+                .visible_worktrees(cx)
+                .next()
+                .expect("should have a worktree");
+            find_configs_in_snapshot(worktree.read(cx))
+        });
+
+        assert!(configs.is_empty());
+    }
+
+    #[gpui::test]
+    async fn test_find_configs_root_json_and_subfolder_configs(cx: &mut TestAppContext) {
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/project"),
+            json!({
+                ".devcontainer.json": "{}",
+                ".devcontainer": {
+                    "rust": {
+                        "devcontainer.json": "{}"
+                    }
+                }
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
+        cx.run_until_parked();
+
+        let configs = project.read_with(cx, |project, cx| {
+            let worktree = project
+                .visible_worktrees(cx)
+                .next()
+                .expect("should have a worktree");
+            find_configs_in_snapshot(worktree.read(cx))
+        });
+
+        assert_eq!(configs.len(), 2);
+        assert_eq!(configs[0].name, "root");
+        assert_eq!(configs[0].config_path, PathBuf::from(".devcontainer.json"));
+        assert_eq!(configs[1].name, "rust");
+        assert_eq!(
+            configs[1].config_path,
+            PathBuf::from(".devcontainer/rust/devcontainer.json")
+        );
+    }
+
+    #[gpui::test]
+    async fn test_find_configs_empty_devcontainer_dir_falls_back_to_root(cx: &mut TestAppContext) {
+        init_test(cx);
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(
+            path!("/project"),
+            json!({
+                ".devcontainer.json": "{}",
+                ".devcontainer": {}
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
+        cx.run_until_parked();
+
+        let configs = project.read_with(cx, |project, cx| {
+            let worktree = project
+                .visible_worktrees(cx)
+                .next()
+                .expect("should have a worktree");
+            find_configs_in_snapshot(worktree.read(cx))
+        });
+
+        assert_eq!(configs.len(), 1);
+        assert_eq!(configs[0], DevContainerConfig::root_config());
+    }
 }

crates/dev_container/src/lib.rs 🔗

@@ -47,7 +47,8 @@ use crate::devcontainer_api::DevContainerError;
 use crate::devcontainer_api::apply_dev_container_template;
 
 pub use devcontainer_api::{
-    DevContainerConfig, find_devcontainer_configs, start_dev_container_with_config,
+    DevContainerConfig, find_configs_in_snapshot, find_devcontainer_configs,
+    start_dev_container_with_config,
 };
 
 #[derive(RegisterSetting)]

crates/recent_projects/src/dev_container_suggest.rs 🔗

@@ -1,4 +1,5 @@
 use db::kvp::KEY_VALUE_STORE;
+use dev_container::find_configs_in_snapshot;
 use gpui::{SharedString, Window};
 use project::{Project, WorktreeId};
 use std::sync::LazyLock;
@@ -11,12 +12,18 @@ use worktree::UpdatedEntriesSet;
 
 const DEV_CONTAINER_SUGGEST_KEY: &str = "dev_container_suggest_dismissed";
 
-fn devcontainer_path() -> &'static RelPath {
+fn devcontainer_dir_path() -> &'static RelPath {
     static PATH: LazyLock<&'static RelPath> =
         LazyLock::new(|| RelPath::unix(".devcontainer").expect("valid path"));
     *PATH
 }
 
+fn devcontainer_json_path() -> &'static RelPath {
+    static PATH: LazyLock<&'static RelPath> =
+        LazyLock::new(|| RelPath::unix(".devcontainer.json").expect("valid path"));
+    *PATH
+}
+
 fn project_devcontainer_key(project_path: &str) -> String {
     format!("{}_{}", DEV_CONTAINER_SUGGEST_KEY, project_path)
 }
@@ -28,9 +35,9 @@ pub fn suggest_on_worktree_updated(
     window: &mut Window,
     cx: &mut Context<Workspace>,
 ) {
-    let devcontainer_updated = updated_entries
-        .iter()
-        .any(|(path, _, _)| path.as_ref() == devcontainer_path());
+    let devcontainer_updated = updated_entries.iter().any(|(path, _, _)| {
+        path.as_ref() == devcontainer_dir_path() || path.as_ref() == devcontainer_json_path()
+    });
 
     if !devcontainer_updated {
         return;
@@ -46,11 +53,7 @@ pub fn suggest_on_worktree_updated(
         return;
     }
 
-    let has_devcontainer = worktree
-        .entry_for_path(devcontainer_path())
-        .is_some_and(|entry| entry.is_dir());
-
-    if !has_devcontainer {
+    if find_configs_in_snapshot(worktree).is_empty() {
         return;
     }