Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:30:27.280Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

devcontainer_api.rs

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