diff --git a/Cargo.lock b/Cargo.lock index d3ea0c1b6b1aed63d3059059672254edbc49b7ba..485554e57f8f41ac6ecb417c713408be8e71b819 100644 --- a/Cargo.lock +++ b/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]] diff --git a/crates/dev_container/Cargo.toml b/crates/dev_container/Cargo.toml index 31f0466d45e84569b3e2609742d5ba2d1ac59568..87a945b97a9e8f3cd3a73a18045960e07405d27c 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 bdba805ade04598d7fba23bbd717d8ec2d584c4f..8d79e7a52ffb43463feb7840573ad6b334b6183b 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: "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//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 { let Some(workspace) = cx.window_handle().downcast::() else { log::debug!("find_devcontainer_configs: No workspace found"); @@ -177,82 +181,100 @@ pub fn find_devcontainer_configs(cx: &mut AsyncWindowContext) -> Vec/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 { + 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) - #[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()); + } } diff --git a/crates/dev_container/src/lib.rs b/crates/dev_container/src/lib.rs index 699285e074f325bea78a240c1a2a696cfc578929..735963825428c60d4af856414206905d127f7309 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 1e50080ea15fad714d17e1648b72455b3d401a7a..fd7fe4757a0f629579c5a5fdae7b16f12f1bba7a 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; @@ -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, ) { - 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; }