@@ -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]]
@@ -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());
+ }
}