Something went wrong. Try again.
fork of gpui
Something went wrong. Try again.
26 kB · 756 lines
Rust
at master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757use 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<String>, #[serde(default)] pub(crate) remote_env: HashMap<String, String>, #[serde(default)] pub(crate) started_at: Option<String>,}
#[derive(Debug)]pub(crate) struct DevContainerApply { pub(crate) project_files: Vec<Arc<RelPath>>,}
#[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<String>),}
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 <id>` and `docker rm <id>`, then try again.", ids.join(", ") ), } ) }}
pub(crate) async fn read_default_devcontainer_configuration( cx: &DevContainerContext, environment: HashMap<String, String>,) -> Result<DevContainer, DevContainerError> { 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<DevContainerConfig> { 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/<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::from_unix_str(".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::from_unix_str(".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::from_unix_str(&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::from_unix_str(".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<DevContainerConfig>, environment: HashMap<String, String>,) -> 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<Worktree>, template: &DevContainerTemplate, template_options: &HashMap<String, String>, features_selected: &HashSet<DevContainerFeature>, context: &DevContainerContext, cx: &mut AsyncWindowContext,) -> Result<DevContainerApply, DevContainerError> { 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::from_unix_str(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<DevContainerFeature>,) -> 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::<serde_json::Map<String, serde_json::Value>>() .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, String>) -> 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()); }}