When testing Zed on our team's Devcontainer I found some missing features/bugs/spec mismatches. Commit messages were written by hand, code reviewed and tested manually, but everything was written by Claude/Codex based on: - https://github.com/devcontainers/cli - https://github.com/devcontainers/spec The main use case of the metadata is that you can `devcontainer exec` and `devcontainer up` into a container Zed started (and vice versa). The same with VSCode, it will reuse Zed's created devcontainer correctly now. This is useful when using LLMs so they can run commands in the same devcontainer you have open. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Fixed Dev Container compatibility and lifecycle behavior.
757 lines
25 KiB
Rust
757 lines
25 KiB
Rust
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| {
|
|
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());
|
|
}
|
|
}
|