From 065f36328275d6b08bb78bce7367780894dd51c9 Mon Sep 17 00:00:00 2001 From: Oliver Azevedo Barnes Date: Mon, 9 Feb 2026 19:58:41 +0000 Subject: [PATCH 1/3] Support `.devcontainer.json` in project root 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. --- Cargo.lock | 3 + crates/dev_container/Cargo.toml | 7 + crates/dev_container/src/devcontainer_api.rs | 430 +++++++++++++++--- crates/dev_container/src/lib.rs | 3 +- .../src/dev_container_suggest.rs | 24 +- 5 files changed, 392 insertions(+), 75 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6c4addac4e4716..e8e8dac593bd7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4942,6 +4942,7 @@ checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" name = "dev_container" version = "0.1.0" dependencies = [ + "fs", "futures 0.3.31", "gpui", "http 1.3.1", @@ -4951,6 +4952,7 @@ dependencies = [ "node_runtime", "paths", "picker", + "project", "serde", "serde_json", "settings", @@ -4958,6 +4960,7 @@ dependencies = [ "ui", "util", "workspace", + "worktree", ] [[package]] diff --git a/crates/dev_container/Cargo.toml b/crates/dev_container/Cargo.toml index 31f0466d45e845..87a945b97a9e8f 100644 --- a/crates/dev_container/Cargo.toml +++ b/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 diff --git a/crates/dev_container/src/devcontainer_api.rs b/crates/dev_container/src/devcontainer_api.rs index bdba805ade0459..60fa75b3a6658c 100644 --- a/crates/dev_container/src/devcontainer_api.rs +++ b/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: "default".to_string(), + config_path: PathBuf::from(".devcontainer.json"), + } + } } #[derive(Debug, Deserialize)] @@ -156,6 +164,10 @@ fn use_podman(cx: &mut AsyncWindowContext) -> bool { /// This function scans for: /// 1. `.devcontainer/devcontainer.json` (the default location) /// 2. `.devcontainer//devcontainer.json` (named configurations) +/// 3. `.devcontainer.json` in the project root (only if no configs found in `.devcontainer/`) +/// +/// Per the devcontainer spec, `.devcontainer/devcontainer.json` takes precedence +/// over `.devcontainer.json` in the root. /// /// Returns a list of found configurations, or an empty list if none are found. pub fn find_devcontainer_configs(cx: &mut AsyncWindowContext) -> Vec { @@ -177,82 +189,100 @@ pub fn find_devcontainer_configs(cx: &mut AsyncWindowContext) -> Vec/devcontainer.json` (named configurations) +/// 3. `.devcontainer.json` in the project root (only if no configs found in `.devcontainer/`) +pub fn find_configs_in_snapshot(snapshot: &Snapshot) -> Vec { + 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() - ); + // Per the devcontainer spec, `.devcontainer.json` in the project root is only + // used when no configurations were found inside the `.devcontainer/` directory. + if configs.is_empty() { + 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.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) - } - }); + log::info!( + "find_configs_in_snapshot: Found {} configurations", + configs.len() + ); - configs - }) else { - log::debug!("find_devcontainer_configs: Failed to update workspace"); - return Vec::new(); - }; + 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) + } + }); configs } @@ -701,7 +731,25 @@ fn template_features_to_json(features_selected: &HashSet) - #[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 +774,252 @@ 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, "default"); + 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_takes_precedence_over_root(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(), 1); + assert_eq!(configs[0], DevContainerConfig::default_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_subfolder_takes_precedence_over_root(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(), 1); + assert_eq!(configs[0].name, "rust"); + assert_eq!( + configs[0].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()); + } } diff --git a/crates/dev_container/src/lib.rs b/crates/dev_container/src/lib.rs index 699285e074f325..735963825428c6 100644 --- a/crates/dev_container/src/lib.rs +++ b/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)] diff --git a/crates/recent_projects/src/dev_container_suggest.rs b/crates/recent_projects/src/dev_container_suggest.rs index 1e50080ea15fad..6bd12222de4171 100644 --- a/crates/recent_projects/src/dev_container_suggest.rs +++ b/crates/recent_projects/src/dev_container_suggest.rs @@ -11,12 +11,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 +34,9 @@ pub fn suggest_on_worktree_updated( window: &mut Window, cx: &mut Context, ) { - 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 +52,15 @@ pub fn suggest_on_worktree_updated( return; } - let has_devcontainer = worktree - .entry_for_path(devcontainer_path()) + let has_devcontainer_dir = worktree + .entry_for_path(devcontainer_dir_path()) .is_some_and(|entry| entry.is_dir()); - if !has_devcontainer { + let has_devcontainer_json = worktree + .entry_for_path(devcontainer_json_path()) + .is_some_and(|entry| entry.is_file()); + + if !has_devcontainer_dir && !has_devcontainer_json { return; } From d27cbd576ede2fe7a203b852eb7a748e650b96f4 Mon Sep 17 00:00:00 2001 From: Oliver Azevedo Barnes Date: Tue, 10 Feb 2026 12:54:30 +0000 Subject: [PATCH 2/3] Fix precedence and include root config in picker Also, reuse `find_configs_in_snapshot` in the suggestion module --- crates/dev_container/src/devcontainer_api.rs | 72 ++++++++++--------- .../src/dev_container_suggest.rs | 11 +-- 2 files changed, 41 insertions(+), 42 deletions(-) diff --git a/crates/dev_container/src/devcontainer_api.rs b/crates/dev_container/src/devcontainer_api.rs index 60fa75b3a6658c..37eccbc80d5cd9 100644 --- a/crates/dev_container/src/devcontainer_api.rs +++ b/crates/dev_container/src/devcontainer_api.rs @@ -35,7 +35,7 @@ impl DevContainerConfig { pub fn root_config() -> Self { Self { - name: "default".to_string(), + name: "root".to_string(), config_path: PathBuf::from(".devcontainer.json"), } } @@ -161,13 +161,14 @@ fn use_podman(cx: &mut AsyncWindowContext) -> bool { /// Finds all available devcontainer configurations in the project. /// -/// This function scans for: +/// Per the devcontainer spec, configurations are searched in this order of precedence: /// 1. `.devcontainer/devcontainer.json` (the default location) -/// 2. `.devcontainer//devcontainer.json` (named configurations) -/// 3. `.devcontainer.json` in the project root (only if no configs found in `.devcontainer/`) +/// 2. `.devcontainer.json` in the project root (used as default when #1 is absent) +/// 3. `.devcontainer//devcontainer.json` (named configurations) /// -/// Per the devcontainer spec, `.devcontainer/devcontainer.json` takes precedence -/// over `.devcontainer.json` in the root. +/// `.devcontainer/devcontainer.json` and `.devcontainer.json` both serve as the +/// "default" config — only the higher-precedence one is included. Subfolder +/// configs are always returned alongside whichever default is found. /// /// Returns a list of found configurations, or an empty list if none are found. pub fn find_devcontainer_configs(cx: &mut AsyncWindowContext) -> Vec { @@ -200,10 +201,14 @@ pub fn find_devcontainer_configs(cx: &mut AsyncWindowContext) -> Vec/devcontainer.json` (named configurations) -/// 3. `.devcontainer.json` in the project root (only if no configs found in `.devcontainer/`) +/// 2. `.devcontainer.json` in the project root (used as default when #1 is absent) +/// 3. `.devcontainer//devcontainer.json` (named configurations) +/// +/// `.devcontainer/devcontainer.json` and `.devcontainer.json` both serve as the +/// "default" config — only the higher-precedence one is included. Subfolder +/// configs are always returned alongside whichever default is found. pub fn find_configs_in_snapshot(snapshot: &Snapshot) -> Vec { let mut configs = Vec::new(); @@ -256,17 +261,15 @@ pub fn find_configs_in_snapshot(snapshot: &Snapshot) -> Vec } } - // Per the devcontainer spec, `.devcontainer.json` in the project root is only - // used when no configurations were found inside the `.devcontainer/` directory. - if configs.is_empty() { - 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()); - } + // 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()); } log::info!( @@ -275,12 +278,12 @@ pub fn find_configs_in_snapshot(snapshot: &Snapshot) -> Vec ); 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) + 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), } }); @@ -799,7 +802,7 @@ mod tests { }); assert_eq!(configs.len(), 1); - assert_eq!(configs[0].name, "default"); + assert_eq!(configs[0].name, "root"); assert_eq!(configs[0].config_path, PathBuf::from(".devcontainer.json")); } @@ -833,7 +836,7 @@ mod tests { } #[gpui::test] - async fn test_find_configs_dir_takes_precedence_over_root(cx: &mut TestAppContext) { + 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( @@ -858,8 +861,9 @@ mod tests { find_configs_in_snapshot(worktree.read(cx)) }); - assert_eq!(configs.len(), 1); + assert_eq!(configs.len(), 2); assert_eq!(configs[0], DevContainerConfig::default_config()); + assert_eq!(configs[1], DevContainerConfig::root_config()); } #[gpui::test] @@ -960,7 +964,7 @@ mod tests { } #[gpui::test] - async fn test_find_configs_subfolder_takes_precedence_over_root(cx: &mut TestAppContext) { + 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( @@ -987,10 +991,12 @@ mod tests { find_configs_in_snapshot(worktree.read(cx)) }); - assert_eq!(configs.len(), 1); - assert_eq!(configs[0].name, "rust"); + 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[0].config_path, + configs[1].config_path, PathBuf::from(".devcontainer/rust/devcontainer.json") ); } diff --git a/crates/recent_projects/src/dev_container_suggest.rs b/crates/recent_projects/src/dev_container_suggest.rs index 6bd12222de4171..fd7fe4757a0f62 100644 --- a/crates/recent_projects/src/dev_container_suggest.rs +++ b/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; @@ -52,15 +53,7 @@ pub fn suggest_on_worktree_updated( return; } - let has_devcontainer_dir = worktree - .entry_for_path(devcontainer_dir_path()) - .is_some_and(|entry| entry.is_dir()); - - let has_devcontainer_json = worktree - .entry_for_path(devcontainer_json_path()) - .is_some_and(|entry| entry.is_file()); - - if !has_devcontainer_dir && !has_devcontainer_json { + if find_configs_in_snapshot(worktree).is_empty() { return; } From c17568d005cff68ed570671111ed9a3b41f9c00d Mon Sep 17 00:00:00 2001 From: Oliver Azevedo Barnes Date: Tue, 10 Feb 2026 18:53:57 +0000 Subject: [PATCH 3/3] Fix and dry up documentation of config finders --- crates/dev_container/src/devcontainer_api.rs | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/crates/dev_container/src/devcontainer_api.rs b/crates/dev_container/src/devcontainer_api.rs index 37eccbc80d5cd9..8d79e7a52ffb43 100644 --- a/crates/dev_container/src/devcontainer_api.rs +++ b/crates/dev_container/src/devcontainer_api.rs @@ -161,16 +161,7 @@ fn use_podman(cx: &mut AsyncWindowContext) -> bool { /// Finds all available devcontainer configurations in the project. /// -/// Per the devcontainer spec, configurations are searched in this order of precedence: -/// 1. `.devcontainer/devcontainer.json` (the default location) -/// 2. `.devcontainer.json` in the project root (used as default when #1 is absent) -/// 3. `.devcontainer//devcontainer.json` (named configurations) -/// -/// `.devcontainer/devcontainer.json` and `.devcontainer.json` both serve as the -/// "default" config — only the higher-precedence one is included. Subfolder -/// configs are always returned alongside whichever default is found. -/// -/// 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 { let Some(workspace) = cx.window_handle().downcast::() else { log::debug!("find_devcontainer_configs: No workspace found"); @@ -201,14 +192,12 @@ pub fn find_devcontainer_configs(cx: &mut AsyncWindowContext) -> Vec/devcontainer.json` (named configurations) /// -/// `.devcontainer/devcontainer.json` and `.devcontainer.json` both serve as the -/// "default" config — only the higher-precedence one is included. Subfolder -/// configs are always returned alongside whichever default is found. +/// All found configurations are returned so the user can pick between them. pub fn find_configs_in_snapshot(snapshot: &Snapshot) -> Vec { let mut configs = Vec::new();