756 lines · 25.3 KB · Rust
RawDownload
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<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| {

Lines 1–500 of 756.