devcontainer_api.rs

  1use std::{
  2    collections::{HashMap, HashSet},
  3    fmt::Display,
  4    path::{Path, PathBuf},
  5    sync::Arc,
  6};
  7
  8use futures::TryFutureExt;
  9use gpui::{AsyncWindowContext, Entity};
 10use project::Worktree;
 11use serde::Deserialize;
 12use settings::{DevContainerConnection, infer_json_indent_size, replace_value_in_json_text};
 13use util::rel_path::RelPath;
 14use walkdir::WalkDir;
 15use workspace::Workspace;
 16use worktree::Snapshot;
 17
 18use crate::{
 19    DevContainerContext, DevContainerFeature, DevContainerTemplate,
 20    devcontainer_json::DevContainer,
 21    devcontainer_manifest::{read_devcontainer_configuration, spawn_dev_container},
 22    devcontainer_templates_repository, get_latest_oci_manifest, get_oci_token, ghcr_registry,
 23    oci::download_oci_tarball,
 24};
 25
 26/// Represents a discovered devcontainer configuration
 27#[derive(Debug, Clone, PartialEq, Eq)]
 28pub struct DevContainerConfig {
 29    /// Display name for the configuration (subfolder name or "default")
 30    pub name: String,
 31    /// Relative path to the devcontainer.json file from the project root
 32    pub config_path: PathBuf,
 33}
 34
 35impl DevContainerConfig {
 36    pub fn default_config() -> Self {
 37        Self {
 38            name: "default".to_string(),
 39            config_path: PathBuf::from(".devcontainer/devcontainer.json"),
 40        }
 41    }
 42
 43    pub fn root_config() -> Self {
 44        Self {
 45            name: "root".to_string(),
 46            config_path: PathBuf::from(".devcontainer.json"),
 47        }
 48    }
 49}
 50
 51#[derive(Debug, Deserialize)]
 52#[serde(rename_all = "camelCase")]
 53pub(crate) struct DevContainerUp {
 54    pub(crate) container_id: String,
 55    pub(crate) remote_user: String,
 56    pub(crate) remote_workspace_folder: String,
 57    #[serde(default)]
 58    pub(crate) extension_ids: Vec<String>,
 59    #[serde(default)]
 60    pub(crate) remote_env: HashMap<String, String>,
 61}
 62
 63#[derive(Debug)]
 64pub(crate) struct DevContainerApply {
 65    pub(crate) project_files: Vec<Arc<RelPath>>,
 66}
 67
 68#[derive(Debug, Clone, PartialEq, Eq)]
 69pub enum DevContainerError {
 70    CommandFailed(String),
 71    DockerNotAvailable,
 72    ContainerNotValid(String),
 73    DevContainerTemplateApplyFailed(String),
 74    DevContainerScriptsFailed,
 75    DevContainerUpFailed(String),
 76    DevContainerNotFound,
 77    DevContainerParseFailed,
 78    DevContainerValidationFailed(String),
 79    FilesystemError,
 80    ResourceFetchFailed,
 81    NotInValidProject,
 82    /// Multiple existing containers match this project's identifying labels
 83    /// (`devcontainer.local_folder` + `devcontainer.config_file`). The spec
 84    /// expects those labels to be unique per project, so Zed can't choose
 85    /// which one to connect to. The user must remove the duplicate(s).
 86    MultipleMatchingContainers(Vec<String>),
 87}
 88
 89impl Display for DevContainerError {
 90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 91        write!(
 92            f,
 93            "{}",
 94            match self {
 95                DevContainerError::DockerNotAvailable =>
 96                    "docker CLI not found on $PATH".to_string(),
 97                DevContainerError::ContainerNotValid(id) => format!(
 98                    "docker image {id} did not have expected configuration for a dev container"
 99                ),
100                DevContainerError::DevContainerScriptsFailed =>
101                    "lifecycle scripts could not execute for dev container".to_string(),
102                DevContainerError::DevContainerUpFailed(_) => {
103                    "DevContainer creation failed".to_string()
104                }
105                DevContainerError::DevContainerTemplateApplyFailed(_) => {
106                    "DevContainer template apply failed".to_string()
107                }
108                DevContainerError::DevContainerNotFound =>
109                    "No valid dev container definition found in project".to_string(),
110                DevContainerError::DevContainerParseFailed =>
111                    "Failed to parse file .devcontainer/devcontainer.json".to_string(),
112                DevContainerError::NotInValidProject => "Not within a valid project".to_string(),
113                DevContainerError::CommandFailed(program) =>
114                    format!("Failure running external program {program}"),
115                DevContainerError::FilesystemError =>
116                    "Error downloading resources locally".to_string(),
117                DevContainerError::ResourceFetchFailed =>
118                    "Failed to fetch resources from template or feature repository".to_string(),
119                DevContainerError::DevContainerValidationFailed(failure) => failure.to_string(),
120                DevContainerError::MultipleMatchingContainers(ids) => format!(
121                    "Multiple containers match this project's dev container labels ({}). \
122                     Zed can't decide which to connect to. Stop and remove the stale one(s) with \
123                     `docker stop <id>` and `docker rm <id>`, then try again.",
124                    ids.join(", ")
125                ),
126            }
127        )
128    }
129}
130
131pub(crate) async fn read_default_devcontainer_configuration(
132    cx: &DevContainerContext,
133    environment: HashMap<String, String>,
134) -> Result<DevContainer, DevContainerError> {
135    let default_config = DevContainerConfig::default_config();
136
137    read_devcontainer_configuration(default_config, cx, environment)
138        .await
139        .map_err(|e| {
140            log::error!("Default configuration not found: {:?}", e);
141            DevContainerError::DevContainerNotFound
142        })
143}
144
145/// Finds all available devcontainer configurations in the project.
146///
147/// See [`find_configs_in_snapshot`] for the locations that are scanned.
148pub fn find_devcontainer_configs(workspace: &Workspace, cx: &gpui::App) -> Vec<DevContainerConfig> {
149    let project = workspace.project().read(cx);
150
151    let worktree = project
152        .visible_worktrees(cx)
153        .find_map(|tree| tree.read(cx).root_entry()?.is_dir().then_some(tree));
154
155    let Some(worktree) = worktree else {
156        log::debug!("find_devcontainer_configs: No worktree found");
157        return Vec::new();
158    };
159
160    let worktree = worktree.read(cx);
161    find_configs_in_snapshot(worktree)
162}
163
164/// Scans a worktree snapshot for devcontainer configurations.
165///
166/// Scans for configurations in these locations:
167/// 1. `.devcontainer/devcontainer.json` (the default location)
168/// 2. `.devcontainer.json` in the project root
169/// 3. `.devcontainer/<subfolder>/devcontainer.json` (named configurations)
170///
171/// All found configurations are returned so the user can pick between them.
172pub fn find_configs_in_snapshot(snapshot: &Snapshot) -> Vec<DevContainerConfig> {
173    let mut configs = Vec::new();
174
175    let devcontainer_dir_path = RelPath::unix(".devcontainer").expect("valid path");
176
177    if let Some(devcontainer_entry) = snapshot.entry_for_path(devcontainer_dir_path) {
178        if devcontainer_entry.is_dir() {
179            log::debug!("find_configs_in_snapshot: Scanning .devcontainer directory");
180            let devcontainer_json_path =
181                RelPath::unix(".devcontainer/devcontainer.json").expect("valid path");
182            for entry in snapshot.child_entries(devcontainer_dir_path) {
183                log::debug!(
184                    "find_configs_in_snapshot: Found entry: {:?}, is_file: {}, is_dir: {}",
185                    entry.path.as_unix_str(),
186                    entry.is_file(),
187                    entry.is_dir()
188                );
189
190                if entry.is_file() && entry.path.as_ref() == devcontainer_json_path {
191                    log::debug!("find_configs_in_snapshot: Found default devcontainer.json");
192                    configs.push(DevContainerConfig::default_config());
193                } else if entry.is_dir() {
194                    let subfolder_name = entry
195                        .path
196                        .file_name()
197                        .map(|n| n.to_string())
198                        .unwrap_or_default();
199
200                    let config_json_path =
201                        format!("{}/devcontainer.json", entry.path.as_unix_str());
202                    if let Ok(rel_config_path) = RelPath::unix(&config_json_path) {
203                        if snapshot.entry_for_path(rel_config_path).is_some() {
204                            log::debug!(
205                                "find_configs_in_snapshot: Found config in subfolder: {}",
206                                subfolder_name
207                            );
208                            configs.push(DevContainerConfig {
209                                name: subfolder_name,
210                                config_path: PathBuf::from(&config_json_path),
211                            });
212                        } else {
213                            log::debug!(
214                                "find_configs_in_snapshot: Subfolder {} has no devcontainer.json",
215                                subfolder_name
216                            );
217                        }
218                    }
219                }
220            }
221        }
222    }
223
224    // Always include `.devcontainer.json` so the user can pick it from the UI
225    // even when `.devcontainer/devcontainer.json` also exists.
226    let root_config_path = RelPath::unix(".devcontainer.json").expect("valid path");
227    if snapshot
228        .entry_for_path(root_config_path)
229        .is_some_and(|entry| entry.is_file())
230    {
231        log::debug!("find_configs_in_snapshot: Found .devcontainer.json in project root");
232        configs.push(DevContainerConfig::root_config());
233    }
234
235    log::info!(
236        "find_configs_in_snapshot: Found {} configurations",
237        configs.len()
238    );
239
240    configs.sort_by(|a, b| {
241        let a_is_primary = a.name == "default" || a.name == "root";
242        let b_is_primary = b.name == "default" || b.name == "root";
243        match (a_is_primary, b_is_primary) {
244            (true, false) => std::cmp::Ordering::Less,
245            (false, true) => std::cmp::Ordering::Greater,
246            _ => a.name.cmp(&b.name),
247        }
248    });
249
250    configs
251}
252
253pub async fn start_dev_container_with_config(
254    context: DevContainerContext,
255    config: Option<DevContainerConfig>,
256    environment: HashMap<String, String>,
257) -> Result<(DevContainerConnection, String), DevContainerError> {
258    check_for_docker(context.use_podman).await?;
259
260    let Some(actual_config) = config.clone() else {
261        return Err(DevContainerError::NotInValidProject);
262    };
263
264    match spawn_dev_container(
265        &context,
266        environment.clone(),
267        actual_config.clone(),
268        context.project_directory.clone().as_ref(),
269    )
270    .await
271    {
272        Ok(DevContainerUp {
273            container_id,
274            remote_workspace_folder,
275            remote_user,
276            extension_ids,
277            remote_env,
278            ..
279        }) => {
280            let project_name =
281                match read_devcontainer_configuration(actual_config, &context, environment).await {
282                    Ok(DevContainer {
283                        name: Some(name), ..
284                    }) => name,
285                    _ => get_backup_project_name(&remote_workspace_folder, &container_id),
286                };
287
288            let connection = DevContainerConnection {
289                name: project_name,
290                container_id,
291                use_podman: context.use_podman,
292                remote_user,
293                extension_ids,
294                remote_env: remote_env.into_iter().collect(),
295            };
296
297            Ok((connection, remote_workspace_folder))
298        }
299        Err(err @ DevContainerError::MultipleMatchingContainers(_)) => Err(err),
300        Err(err) => {
301            let message = format!("Failed with nested error: {:?}", err);
302            Err(DevContainerError::DevContainerUpFailed(message))
303        }
304    }
305}
306
307async fn check_for_docker(use_podman: bool) -> Result<(), DevContainerError> {
308    let mut command = if use_podman {
309        util::command::new_command("podman")
310    } else {
311        util::command::new_command("docker")
312    };
313    command.arg("--version");
314
315    match command.output().await {
316        Ok(_) => Ok(()),
317        Err(e) => {
318            log::error!("Unable to find docker in $PATH: {:?}", e);
319            Err(DevContainerError::DockerNotAvailable)
320        }
321    }
322}
323
324pub(crate) async fn apply_devcontainer_template(
325    worktree: Entity<Worktree>,
326    template: &DevContainerTemplate,
327    template_options: &HashMap<String, String>,
328    features_selected: &HashSet<DevContainerFeature>,
329    context: &DevContainerContext,
330    cx: &mut AsyncWindowContext,
331) -> Result<DevContainerApply, DevContainerError> {
332    let token = get_oci_token(
333        ghcr_registry(),
334        devcontainer_templates_repository(),
335        &context.http_client,
336    )
337    .map_err(|e| {
338        log::error!("Failed to get OCI auth token: {e}");
339        DevContainerError::ResourceFetchFailed
340    })
341    .await?;
342    let manifest = get_latest_oci_manifest(
343        &token.token,
344        ghcr_registry(),
345        devcontainer_templates_repository(),
346        &context.http_client,
347        Some(&template.id),
348    )
349    .map_err(|e| {
350        log::error!("Failed to fetch template from OCI repository: {e}");
351        DevContainerError::ResourceFetchFailed
352    })
353    .await?;
354
355    let layer = &manifest.layers.get(0).ok_or_else(|| {
356        log::error!("Given manifest has no layers to query for blob. Aborting");
357        DevContainerError::ResourceFetchFailed
358    })?;
359
360    let timestamp = std::time::SystemTime::now()
361        .duration_since(std::time::UNIX_EPOCH)
362        .map(|d| d.as_millis())
363        .unwrap_or(0);
364    let extract_dir = std::env::temp_dir()
365        .join(&template.id)
366        .join(format!("extracted-{timestamp}"));
367
368    context.fs.create_dir(&extract_dir).await.map_err(|e| {
369        log::error!("Could not create temporary directory: {e}");
370        DevContainerError::FilesystemError
371    })?;
372
373    download_oci_tarball(
374        &token.token,
375        ghcr_registry(),
376        devcontainer_templates_repository(),
377        &layer.digest,
378        "application/vnd.oci.image.manifest.v1+json",
379        &extract_dir,
380        &context.http_client,
381        &context.fs,
382        Some(&template.id),
383    )
384    .map_err(|e| {
385        log::error!("Error downloading tarball: {:?}", e);
386        DevContainerError::ResourceFetchFailed
387    })
388    .await?;
389
390    let downloaded_devcontainer_folder = &extract_dir.join(".devcontainer/");
391    let mut project_files = Vec::new();
392    for entry in WalkDir::new(downloaded_devcontainer_folder) {
393        let Ok(entry) = entry else {
394            continue;
395        };
396        if !entry.file_type().is_file() {
397            continue;
398        }
399        let relative_path = entry.path().strip_prefix(&extract_dir).map_err(|e| {
400            log::error!("Can't create relative path: {e}");
401            DevContainerError::FilesystemError
402        })?;
403        let rel_path = RelPath::unix(relative_path)
404            .map_err(|e| {
405                log::error!("Can't create relative path: {e}");
406                DevContainerError::FilesystemError
407            })?
408            .into_arc();
409        let content = context.fs.load(entry.path()).await.map_err(|e| {
410            log::error!("Unable to read file: {e}");
411            DevContainerError::FilesystemError
412        })?;
413
414        let mut content = expand_template_options(content, template_options);
415        if let Some("devcontainer.json") = &rel_path.file_name() {
416            content = insert_features_into_devcontainer_json(&content, features_selected)
417        }
418        worktree
419            .update(cx, |worktree, cx| {
420                worktree.create_entry(rel_path.clone(), false, Some(content.into_bytes()), cx)
421            })
422            .await
423            .map_err(|e| {
424                log::error!("Unable to create entry in worktree: {e}");
425                DevContainerError::NotInValidProject
426            })?;
427        project_files.push(rel_path);
428    }
429
430    Ok(DevContainerApply { project_files })
431}
432
433fn insert_features_into_devcontainer_json(
434    content: &str,
435    features: &HashSet<DevContainerFeature>,
436) -> String {
437    if features.is_empty() {
438        return content.to_string();
439    }
440
441    let features_value: serde_json::Value = features
442        .iter()
443        .map(|f| {
444            let key = format!(
445                "{}/{}:{}",
446                f.source_repository.as_deref().unwrap_or(""),
447                f.id,
448                f.major_version()
449            );
450            (key, serde_json::Value::Object(Default::default()))
451        })
452        .collect::<serde_json::Map<String, serde_json::Value>>()
453        .into();
454
455    let tab_size = infer_json_indent_size(content);
456    let (range, replacement) = replace_value_in_json_text(
457        content,
458        &["features"],
459        tab_size,
460        Some(&features_value),
461        None,
462    );
463
464    let mut result = content.to_string();
465    result.replace_range(range, &replacement);
466    result
467}
468
469fn expand_template_options(content: String, template_options: &HashMap<String, String>) -> String {
470    let mut replaced_content = content;
471    for (key, val) in template_options {
472        replaced_content = replaced_content.replace(&format!("${{templateOption:{key}}}"), val)
473    }
474    replaced_content
475}
476
477fn get_backup_project_name(remote_workspace_folder: &str, container_id: &str) -> String {
478    Path::new(remote_workspace_folder)
479        .file_name()
480        .and_then(|name| name.to_str())
481        .map(|string| string.to_string())
482        .unwrap_or_else(|| container_id.to_string())
483}
484
485#[cfg(test)]
486mod tests {
487    use std::path::PathBuf;
488
489    use crate::devcontainer_api::{DevContainerConfig, find_configs_in_snapshot};
490    use fs::FakeFs;
491    use gpui::TestAppContext;
492    use project::Project;
493    use serde_json::json;
494    use settings::SettingsStore;
495    use util::path;
496
497    fn init_test(cx: &mut TestAppContext) {
498        cx.update(|cx| {
499            let settings_store = SettingsStore::test(cx);
500            cx.set_global(settings_store);
501        });
502    }
503
504    #[gpui::test]
505    async fn test_find_configs_root_devcontainer_json(cx: &mut TestAppContext) {
506        init_test(cx);
507        let fs = FakeFs::new(cx.executor());
508        fs.insert_tree(
509            path!("/project"),
510            json!({
511                ".devcontainer.json": "{}"
512            }),
513        )
514        .await;
515
516        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
517        cx.run_until_parked();
518
519        let configs = project.read_with(cx, |project, cx| {
520            let worktree = project
521                .visible_worktrees(cx)
522                .next()
523                .expect("should have a worktree");
524            find_configs_in_snapshot(worktree.read(cx))
525        });
526
527        assert_eq!(configs.len(), 1);
528        assert_eq!(configs[0].name, "root");
529        assert_eq!(configs[0].config_path, PathBuf::from(".devcontainer.json"));
530    }
531
532    #[gpui::test]
533    async fn test_find_configs_default_devcontainer_dir(cx: &mut TestAppContext) {
534        init_test(cx);
535        let fs = FakeFs::new(cx.executor());
536        fs.insert_tree(
537            path!("/project"),
538            json!({
539                ".devcontainer": {
540                    "devcontainer.json": "{}"
541                }
542            }),
543        )
544        .await;
545
546        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
547        cx.run_until_parked();
548
549        let configs = project.read_with(cx, |project, cx| {
550            let worktree = project
551                .visible_worktrees(cx)
552                .next()
553                .expect("should have a worktree");
554            find_configs_in_snapshot(worktree.read(cx))
555        });
556
557        assert_eq!(configs.len(), 1);
558        assert_eq!(configs[0], DevContainerConfig::default_config());
559    }
560
561    #[gpui::test]
562    async fn test_find_configs_dir_and_root_both_included(cx: &mut TestAppContext) {
563        init_test(cx);
564        let fs = FakeFs::new(cx.executor());
565        fs.insert_tree(
566            path!("/project"),
567            json!({
568                ".devcontainer.json": "{}",
569                ".devcontainer": {
570                    "devcontainer.json": "{}"
571                }
572            }),
573        )
574        .await;
575
576        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
577        cx.run_until_parked();
578
579        let configs = project.read_with(cx, |project, cx| {
580            let worktree = project
581                .visible_worktrees(cx)
582                .next()
583                .expect("should have a worktree");
584            find_configs_in_snapshot(worktree.read(cx))
585        });
586
587        assert_eq!(configs.len(), 2);
588        assert_eq!(configs[0], DevContainerConfig::default_config());
589        assert_eq!(configs[1], DevContainerConfig::root_config());
590    }
591
592    #[gpui::test]
593    async fn test_find_configs_subfolder_configs(cx: &mut TestAppContext) {
594        init_test(cx);
595        let fs = FakeFs::new(cx.executor());
596        fs.insert_tree(
597            path!("/project"),
598            json!({
599                ".devcontainer": {
600                    "rust": {
601                        "devcontainer.json": "{}"
602                    },
603                    "python": {
604                        "devcontainer.json": "{}"
605                    }
606                }
607            }),
608        )
609        .await;
610
611        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
612        cx.run_until_parked();
613
614        let configs = project.read_with(cx, |project, cx| {
615            let worktree = project
616                .visible_worktrees(cx)
617                .next()
618                .expect("should have a worktree");
619            find_configs_in_snapshot(worktree.read(cx))
620        });
621
622        assert_eq!(configs.len(), 2);
623        let names: Vec<&str> = configs.iter().map(|c| c.name.as_str()).collect();
624        assert!(names.contains(&"python"));
625        assert!(names.contains(&"rust"));
626    }
627
628    #[gpui::test]
629    async fn test_find_configs_default_and_subfolder(cx: &mut TestAppContext) {
630        init_test(cx);
631        let fs = FakeFs::new(cx.executor());
632        fs.insert_tree(
633            path!("/project"),
634            json!({
635                ".devcontainer": {
636                    "devcontainer.json": "{}",
637                    "gpu": {
638                        "devcontainer.json": "{}"
639                    }
640                }
641            }),
642        )
643        .await;
644
645        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
646        cx.run_until_parked();
647
648        let configs = project.read_with(cx, |project, cx| {
649            let worktree = project
650                .visible_worktrees(cx)
651                .next()
652                .expect("should have a worktree");
653            find_configs_in_snapshot(worktree.read(cx))
654        });
655
656        assert_eq!(configs.len(), 2);
657        assert_eq!(configs[0].name, "default");
658        assert_eq!(configs[1].name, "gpu");
659    }
660
661    #[gpui::test]
662    async fn test_find_configs_no_devcontainer(cx: &mut TestAppContext) {
663        init_test(cx);
664        let fs = FakeFs::new(cx.executor());
665        fs.insert_tree(
666            path!("/project"),
667            json!({
668                "src": {
669                    "main.rs": "fn main() {}"
670                }
671            }),
672        )
673        .await;
674
675        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
676        cx.run_until_parked();
677
678        let configs = project.read_with(cx, |project, cx| {
679            let worktree = project
680                .visible_worktrees(cx)
681                .next()
682                .expect("should have a worktree");
683            find_configs_in_snapshot(worktree.read(cx))
684        });
685
686        assert!(configs.is_empty());
687    }
688
689    #[gpui::test]
690    async fn test_find_configs_root_json_and_subfolder_configs(cx: &mut TestAppContext) {
691        init_test(cx);
692        let fs = FakeFs::new(cx.executor());
693        fs.insert_tree(
694            path!("/project"),
695            json!({
696                ".devcontainer.json": "{}",
697                ".devcontainer": {
698                    "rust": {
699                        "devcontainer.json": "{}"
700                    }
701                }
702            }),
703        )
704        .await;
705
706        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
707        cx.run_until_parked();
708
709        let configs = project.read_with(cx, |project, cx| {
710            let worktree = project
711                .visible_worktrees(cx)
712                .next()
713                .expect("should have a worktree");
714            find_configs_in_snapshot(worktree.read(cx))
715        });
716
717        assert_eq!(configs.len(), 2);
718        assert_eq!(configs[0].name, "root");
719        assert_eq!(configs[0].config_path, PathBuf::from(".devcontainer.json"));
720        assert_eq!(configs[1].name, "rust");
721        assert_eq!(
722            configs[1].config_path,
723            PathBuf::from(".devcontainer/rust/devcontainer.json")
724        );
725    }
726
727    #[gpui::test]
728    async fn test_find_configs_empty_devcontainer_dir_falls_back_to_root(cx: &mut TestAppContext) {
729        init_test(cx);
730        let fs = FakeFs::new(cx.executor());
731        fs.insert_tree(
732            path!("/project"),
733            json!({
734                ".devcontainer.json": "{}",
735                ".devcontainer": {}
736            }),
737        )
738        .await;
739
740        let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
741        cx.run_until_parked();
742
743        let configs = project.read_with(cx, |project, cx| {
744            let worktree = project
745                .visible_worktrees(cx)
746                .next()
747                .expect("should have a worktree");
748            find_configs_in_snapshot(worktree.read(cx))
749        });
750
751        assert_eq!(configs.len(), 1);
752        assert_eq!(configs[0], DevContainerConfig::root_config());
753    }
754}