use std::{ collections::{HashMap, HashSet}, fmt::Display, path::{Path, PathBuf}, sync::Arc, }; use futures::TryFutureExt; use gpui::{AsyncWindowContext, Entity}; use project::Worktree; use serde::Deserialize; use settings::{DevContainerConnection, infer_json_indent_size, replace_value_in_json_text}; use util::rel_path::RelPath; use walkdir::WalkDir; use workspace::Workspace; use worktree::Snapshot; use crate::{ DevContainerContext, DevContainerFeature, DevContainerTemplate, devcontainer_json::DevContainer, devcontainer_manifest::{read_devcontainer_configuration, spawn_dev_container}, devcontainer_templates_repository, get_latest_oci_manifest, get_oci_token, ghcr_registry, oci::download_oci_tarball, }; /// Represents a discovered devcontainer configuration #[derive(Debug, Clone, PartialEq, Eq)] pub struct DevContainerConfig { /// Display name for the configuration (subfolder name or "default") pub name: String, /// Relative path to the devcontainer.json file from the project root pub config_path: PathBuf, } impl DevContainerConfig { pub fn default_config() -> Self { Self { name: "default".to_string(), 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)] #[serde(rename_all = "camelCase")] pub(crate) struct DevContainerUp { pub(crate) container_id: String, pub(crate) remote_user: String, pub(crate) remote_workspace_folder: String, #[serde(default)] pub(crate) extension_ids: Vec, #[serde(default)] pub(crate) remote_env: HashMap, } #[derive(Debug)] pub(crate) struct DevContainerApply { pub(crate) project_files: Vec>, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum DevContainerError { CommandFailed(String), DockerNotAvailable, ContainerNotValid(String), DevContainerTemplateApplyFailed(String), DevContainerScriptsFailed, DevContainerUpFailed(String), DevContainerNotFound, DevContainerParseFailed, DevContainerValidationFailed(String), FilesystemError, ResourceFetchFailed, NotInValidProject, /// Multiple existing containers match this project's identifying labels /// (`devcontainer.local_folder` + `devcontainer.config_file`). The spec /// expects those labels to be unique per project, so Zed can't choose /// which one to connect to. The user must remove the duplicate(s). MultipleMatchingContainers(Vec), } impl Display for DevContainerError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, "{}", match self { DevContainerError::DockerNotAvailable => "docker CLI not found on $PATH".to_string(), DevContainerError::ContainerNotValid(id) => format!( "docker image {id} did not have expected configuration for a dev container" ), DevContainerError::DevContainerScriptsFailed => "lifecycle scripts could not execute for dev container".to_string(), DevContainerError::DevContainerUpFailed(_) => { "DevContainer creation failed".to_string() } DevContainerError::DevContainerTemplateApplyFailed(_) => { "DevContainer template apply failed".to_string() } DevContainerError::DevContainerNotFound => "No valid dev container definition found in project".to_string(), DevContainerError::DevContainerParseFailed => "Failed to parse file .devcontainer/devcontainer.json".to_string(), DevContainerError::NotInValidProject => "Not within a valid project".to_string(), DevContainerError::CommandFailed(program) => format!("Failure running external program {program}"), DevContainerError::FilesystemError => "Error downloading resources locally".to_string(), DevContainerError::ResourceFetchFailed => "Failed to fetch resources from template or feature repository".to_string(), DevContainerError::DevContainerValidationFailed(failure) => failure.to_string(), DevContainerError::MultipleMatchingContainers(ids) => format!( "Multiple containers match this project's dev container labels ({}). \ Zed can't decide which to connect to. Stop and remove the stale one(s) with \ `docker stop ` and `docker rm `, then try again.", ids.join(", ") ), } ) } } pub(crate) async fn read_default_devcontainer_configuration( cx: &DevContainerContext, environment: HashMap, ) -> Result { let default_config = DevContainerConfig::default_config(); read_devcontainer_configuration(default_config, cx, environment) .await .map_err(|e| { log::error!("Default configuration not found: {:?}", e); DevContainerError::DevContainerNotFound }) } /// Finds all available devcontainer configurations in the project. /// /// See [`find_configs_in_snapshot`] for the locations that are scanned. pub fn find_devcontainer_configs(workspace: &Workspace, cx: &gpui::App) -> Vec { let project = workspace.project().read(cx); let worktree = project .visible_worktrees(cx) .find_map(|tree| tree.read(cx).root_entry()?.is_dir().then_some(tree)); let Some(worktree) = worktree else { log::debug!("find_devcontainer_configs: No worktree found"); return Vec::new(); }; let worktree = worktree.read(cx); find_configs_in_snapshot(worktree) } /// 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//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_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 ); } } } } } } // 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!( "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 } pub async fn start_dev_container_with_config( context: DevContainerContext, config: Option, environment: HashMap, ) -> Result<(DevContainerConnection, String), DevContainerError> { check_for_docker(context.use_podman).await?; let Some(actual_config) = config.clone() else { return Err(DevContainerError::NotInValidProject); }; match spawn_dev_container( &context, environment.clone(), actual_config.clone(), context.project_directory.clone().as_ref(), ) .await { Ok(DevContainerUp { container_id, remote_workspace_folder, remote_user, extension_ids, remote_env, .. }) => { let project_name = match read_devcontainer_configuration(actual_config, &context, environment).await { Ok(DevContainer { name: Some(name), .. }) => name, _ => get_backup_project_name(&remote_workspace_folder, &container_id), }; let connection = DevContainerConnection { name: project_name, container_id, use_podman: context.use_podman, remote_user, extension_ids, remote_env: remote_env.into_iter().collect(), }; Ok((connection, remote_workspace_folder)) } Err(err @ DevContainerError::MultipleMatchingContainers(_)) => Err(err), Err(err) => { let message = format!("Failed with nested error: {:?}", err); Err(DevContainerError::DevContainerUpFailed(message)) } } } async fn check_for_docker(use_podman: bool) -> Result<(), DevContainerError> { let mut command = if use_podman { util::command::new_command("podman") } else { util::command::new_command("docker") }; command.arg("--version"); match command.output().await { Ok(_) => Ok(()), Err(e) => { log::error!("Unable to find docker in $PATH: {:?}", e); Err(DevContainerError::DockerNotAvailable) } } } pub(crate) async fn apply_devcontainer_template( worktree: Entity, template: &DevContainerTemplate, template_options: &HashMap, features_selected: &HashSet, context: &DevContainerContext, cx: &mut AsyncWindowContext, ) -> Result { let token = get_oci_token( ghcr_registry(), devcontainer_templates_repository(), &context.http_client, ) .map_err(|e| { log::error!("Failed to get OCI auth token: {e}"); DevContainerError::ResourceFetchFailed }) .await?; let manifest = get_latest_oci_manifest( &token.token, ghcr_registry(), devcontainer_templates_repository(), &context.http_client, Some(&template.id), ) .map_err(|e| { log::error!("Failed to fetch template from OCI repository: {e}"); DevContainerError::ResourceFetchFailed }) .await?; let layer = &manifest.layers.get(0).ok_or_else(|| { log::error!("Given manifest has no layers to query for blob. Aborting"); DevContainerError::ResourceFetchFailed })?; let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis()) .unwrap_or(0); let extract_dir = std::env::temp_dir() .join(&template.id) .join(format!("extracted-{timestamp}")); context.fs.create_dir(&extract_dir).await.map_err(|e| { log::error!("Could not create temporary directory: {e}"); DevContainerError::FilesystemError })?; download_oci_tarball( &token.token, ghcr_registry(), devcontainer_templates_repository(), &layer.digest, "application/vnd.oci.image.manifest.v1+json", &extract_dir, &context.http_client, &context.fs, Some(&template.id), ) .map_err(|e| { log::error!("Error downloading tarball: {:?}", e); DevContainerError::ResourceFetchFailed }) .await?; let downloaded_devcontainer_folder = &extract_dir.join(".devcontainer/"); let mut project_files = Vec::new(); for entry in WalkDir::new(downloaded_devcontainer_folder) { let Ok(entry) = entry else { continue; }; if !entry.file_type().is_file() { continue; } let relative_path = entry.path().strip_prefix(&extract_dir).map_err(|e| { log::error!("Can't create relative path: {e}"); DevContainerError::FilesystemError })?; let rel_path = RelPath::unix(relative_path) .map_err(|e| { log::error!("Can't create relative path: {e}"); DevContainerError::FilesystemError })? .into_arc(); let content = context.fs.load(entry.path()).await.map_err(|e| { log::error!("Unable to read file: {e}"); DevContainerError::FilesystemError })?; let mut content = expand_template_options(content, template_options); if let Some("devcontainer.json") = &rel_path.file_name() { content = insert_features_into_devcontainer_json(&content, features_selected) } worktree .update(cx, |worktree, cx| { worktree.create_entry(rel_path.clone(), false, Some(content.into_bytes()), cx) }) .await .map_err(|e| { log::error!("Unable to create entry in worktree: {e}"); DevContainerError::NotInValidProject })?; project_files.push(rel_path); } Ok(DevContainerApply { project_files }) } fn insert_features_into_devcontainer_json( content: &str, features: &HashSet, ) -> String { if features.is_empty() { return content.to_string(); } let features_value: serde_json::Value = features .iter() .map(|f| { let key = format!( "{}/{}:{}", f.source_repository.as_deref().unwrap_or(""), f.id, f.major_version() ); (key, serde_json::Value::Object(Default::default())) }) .collect::>() .into(); let tab_size = infer_json_indent_size(content); let (range, replacement) = replace_value_in_json_text( content, &["features"], tab_size, Some(&features_value), None, ); let mut result = content.to_string(); result.replace_range(range, &replacement); result } fn expand_template_options(content: String, template_options: &HashMap) -> String { let mut replaced_content = content; for (key, val) in template_options { replaced_content = replaced_content.replace(&format!("${{templateOption:{key}}}"), val) } replaced_content } fn get_backup_project_name(remote_workspace_folder: &str, container_id: &str) -> String { Path::new(remote_workspace_folder) .file_name() .and_then(|name| name.to_str()) .map(|string| string.to_string()) .unwrap_or_else(|| container_id.to_string()) } #[cfg(test)] mod tests { use std::path::PathBuf; use crate::devcontainer_api::{DevContainerConfig, find_configs_in_snapshot}; use fs::FakeFs; use gpui::TestAppContext; use project::Project; use serde_json::json; use settings::SettingsStore; use util::path; fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); }); } #[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()); } }