Skip to repository content757 lines · 26.0 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:28:04.838Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
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 #[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