1use std::{
2 collections::{HashMap, HashSet},
3 fmt::Display,
4 path::{Path, PathBuf},
5};
6
7use node_runtime::NodeRuntime;
8use serde::Deserialize;
9use settings::DevContainerConnection;
10use smol::fs;
11use util::command::Command;
12use util::rel_path::RelPath;
13use workspace::Workspace;
14use worktree::Snapshot;
15
16use crate::{DevContainerContext, DevContainerFeature, DevContainerTemplate};
17
18/// Represents a discovered devcontainer configuration
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct DevContainerConfig {
21 /// Display name for the configuration (subfolder name or "default")
22 pub name: String,
23 /// Relative path to the devcontainer.json file from the project root
24 pub config_path: PathBuf,
25}
26
27impl DevContainerConfig {
28 pub fn default_config() -> Self {
29 Self {
30 name: "default".to_string(),
31 config_path: PathBuf::from(".devcontainer/devcontainer.json"),
32 }
33 }
34
35 pub fn root_config() -> Self {
36 Self {
37 name: "root".to_string(),
38 config_path: PathBuf::from(".devcontainer.json"),
39 }
40 }
41}
42
43#[derive(Debug, Deserialize)]
44#[serde(rename_all = "camelCase")]
45struct DevContainerUp {
46 _outcome: String,
47 container_id: String,
48 remote_user: String,
49 remote_workspace_folder: String,
50}
51
52#[derive(Debug, Deserialize)]
53#[serde(rename_all = "camelCase")]
54pub(crate) struct DevContainerApply {
55 pub(crate) files: Vec<String>,
56}
57
58#[derive(Debug, Deserialize)]
59#[serde(rename_all = "camelCase")]
60pub(crate) struct DevContainerConfiguration {
61 name: Option<String>,
62}
63
64#[derive(Debug, Deserialize)]
65pub(crate) struct DevContainerConfigurationOutput {
66 configuration: DevContainerConfiguration,
67}
68
69pub(crate) struct DevContainerCli {
70 pub path: PathBuf,
71 node_runtime_path: Option<PathBuf>,
72}
73
74impl DevContainerCli {
75 fn command(&self, use_podman: bool) -> Command {
76 let mut command = if let Some(node_runtime_path) = &self.node_runtime_path {
77 let mut command =
78 util::command::new_command(node_runtime_path.as_os_str().display().to_string());
79 command.arg(self.path.display().to_string());
80 command
81 } else {
82 util::command::new_command(self.path.display().to_string())
83 };
84
85 if use_podman {
86 command.arg("--docker-path");
87 command.arg("podman");
88 }
89 command
90 }
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub enum DevContainerError {
95 DockerNotAvailable,
96 DevContainerCliNotAvailable,
97 DevContainerTemplateApplyFailed(String),
98 DevContainerUpFailed(String),
99 DevContainerNotFound,
100 DevContainerParseFailed,
101 NodeRuntimeNotAvailable,
102 NotInValidProject,
103}
104
105impl Display for DevContainerError {
106 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107 write!(
108 f,
109 "{}",
110 match self {
111 DevContainerError::DockerNotAvailable =>
112 "docker CLI not found on $PATH".to_string(),
113 DevContainerError::DevContainerCliNotAvailable =>
114 "devcontainer CLI not found on path".to_string(),
115 DevContainerError::DevContainerUpFailed(_) => {
116 "DevContainer creation failed".to_string()
117 }
118 DevContainerError::DevContainerTemplateApplyFailed(_) => {
119 "DevContainer template apply failed".to_string()
120 }
121 DevContainerError::DevContainerNotFound =>
122 "No valid dev container definition found in project".to_string(),
123 DevContainerError::DevContainerParseFailed =>
124 "Failed to parse file .devcontainer/devcontainer.json".to_string(),
125 DevContainerError::NodeRuntimeNotAvailable =>
126 "Cannot find a valid node runtime".to_string(),
127 DevContainerError::NotInValidProject => "Not within a valid project".to_string(),
128 }
129 )
130 }
131}
132
133/// Finds all available devcontainer configurations in the project.
134///
135/// See [`find_configs_in_snapshot`] for the locations that are scanned.
136pub fn find_devcontainer_configs(workspace: &Workspace, cx: &gpui::App) -> Vec<DevContainerConfig> {
137 let project = workspace.project().read(cx);
138
139 let worktree = project
140 .visible_worktrees(cx)
141 .find_map(|tree| tree.read(cx).root_entry()?.is_dir().then_some(tree));
142
143 let Some(worktree) = worktree else {
144 log::debug!("find_devcontainer_configs: No worktree found");
145 return Vec::new();
146 };
147
148 let worktree = worktree.read(cx);
149 find_configs_in_snapshot(worktree)
150}
151
152/// Scans a worktree snapshot for devcontainer configurations.
153///
154/// Scans for configurations in these locations:
155/// 1. `.devcontainer/devcontainer.json` (the default location)
156/// 2. `.devcontainer.json` in the project root
157/// 3. `.devcontainer/<subfolder>/devcontainer.json` (named configurations)
158///
159/// All found configurations are returned so the user can pick between them.
160pub fn find_configs_in_snapshot(snapshot: &Snapshot) -> Vec<DevContainerConfig> {
161 let mut configs = Vec::new();
162
163 let devcontainer_dir_path = RelPath::unix(".devcontainer").expect("valid path");
164
165 if let Some(devcontainer_entry) = snapshot.entry_for_path(devcontainer_dir_path) {
166 if devcontainer_entry.is_dir() {
167 log::debug!("find_configs_in_snapshot: Scanning .devcontainer directory");
168 let devcontainer_json_path =
169 RelPath::unix(".devcontainer/devcontainer.json").expect("valid path");
170 for entry in snapshot.child_entries(devcontainer_dir_path) {
171 log::debug!(
172 "find_configs_in_snapshot: Found entry: {:?}, is_file: {}, is_dir: {}",
173 entry.path.as_unix_str(),
174 entry.is_file(),
175 entry.is_dir()
176 );
177
178 if entry.is_file() && entry.path.as_ref() == devcontainer_json_path {
179 log::debug!("find_configs_in_snapshot: Found default devcontainer.json");
180 configs.push(DevContainerConfig::default_config());
181 } else if entry.is_dir() {
182 let subfolder_name = entry
183 .path
184 .file_name()
185 .map(|n| n.to_string())
186 .unwrap_or_default();
187
188 let config_json_path =
189 format!("{}/devcontainer.json", entry.path.as_unix_str());
190 if let Ok(rel_config_path) = RelPath::unix(&config_json_path) {
191 if snapshot.entry_for_path(rel_config_path).is_some() {
192 log::debug!(
193 "find_configs_in_snapshot: Found config in subfolder: {}",
194 subfolder_name
195 );
196 configs.push(DevContainerConfig {
197 name: subfolder_name,
198 config_path: PathBuf::from(&config_json_path),
199 });
200 } else {
201 log::debug!(
202 "find_configs_in_snapshot: Subfolder {} has no devcontainer.json",
203 subfolder_name
204 );
205 }
206 }
207 }
208 }
209 }
210 }
211
212 // Always include `.devcontainer.json` so the user can pick it from the UI
213 // even when `.devcontainer/devcontainer.json` also exists.
214 let root_config_path = RelPath::unix(".devcontainer.json").expect("valid path");
215 if snapshot
216 .entry_for_path(root_config_path)
217 .is_some_and(|entry| entry.is_file())
218 {
219 log::debug!("find_configs_in_snapshot: Found .devcontainer.json in project root");
220 configs.push(DevContainerConfig::root_config());
221 }
222
223 log::info!(
224 "find_configs_in_snapshot: Found {} configurations",
225 configs.len()
226 );
227
228 configs.sort_by(|a, b| {
229 let a_is_primary = a.name == "default" || a.name == "root";
230 let b_is_primary = b.name == "default" || b.name == "root";
231 match (a_is_primary, b_is_primary) {
232 (true, false) => std::cmp::Ordering::Less,
233 (false, true) => std::cmp::Ordering::Greater,
234 _ => a.name.cmp(&b.name),
235 }
236 });
237
238 configs
239}
240
241pub async fn start_dev_container_with_config(
242 context: DevContainerContext,
243 config: Option<DevContainerConfig>,
244) -> Result<(DevContainerConnection, String), DevContainerError> {
245 check_for_docker(context.use_podman).await?;
246 let cli = ensure_devcontainer_cli(&context.node_runtime).await?;
247 let config_path = config.map(|c| context.project_directory.join(&c.config_path));
248
249 match devcontainer_up(&context, &cli, config_path.as_deref()).await {
250 Ok(DevContainerUp {
251 container_id,
252 remote_workspace_folder,
253 remote_user,
254 ..
255 }) => {
256 let project_name =
257 match read_devcontainer_configuration(&context, &cli, config_path.as_deref()).await
258 {
259 Ok(DevContainerConfigurationOutput {
260 configuration:
261 DevContainerConfiguration {
262 name: Some(project_name),
263 },
264 }) => project_name,
265 _ => get_backup_project_name(&remote_workspace_folder, &container_id),
266 };
267
268 let connection = DevContainerConnection {
269 name: project_name,
270 container_id,
271 use_podman: context.use_podman,
272 remote_user,
273 };
274
275 Ok((connection, remote_workspace_folder))
276 }
277 Err(err) => {
278 let message = format!("Failed with nested error: {}", err);
279 Err(DevContainerError::DevContainerUpFailed(message))
280 }
281 }
282}
283
284#[cfg(not(target_os = "windows"))]
285fn dev_container_cli() -> String {
286 "devcontainer".to_string()
287}
288
289#[cfg(target_os = "windows")]
290fn dev_container_cli() -> String {
291 "devcontainer.cmd".to_string()
292}
293
294fn dev_container_script() -> String {
295 "devcontainer.js".to_string()
296}
297
298async fn check_for_docker(use_podman: bool) -> Result<(), DevContainerError> {
299 let mut command = if use_podman {
300 util::command::new_command("podman")
301 } else {
302 util::command::new_command("docker")
303 };
304 command.arg("--version");
305
306 match command.output().await {
307 Ok(_) => Ok(()),
308 Err(e) => {
309 log::error!("Unable to find docker in $PATH: {:?}", e);
310 Err(DevContainerError::DockerNotAvailable)
311 }
312 }
313}
314
315pub(crate) async fn ensure_devcontainer_cli(
316 node_runtime: &NodeRuntime,
317) -> Result<DevContainerCli, DevContainerError> {
318 let mut command = util::command::new_command(&dev_container_cli());
319 command.arg("--version");
320
321 if let Err(e) = command.output().await {
322 log::error!(
323 "Unable to find devcontainer CLI in $PATH. Checking for a zed installed version. Error: {:?}",
324 e
325 );
326
327 let Ok(node_runtime_path) = node_runtime.binary_path().await else {
328 return Err(DevContainerError::NodeRuntimeNotAvailable);
329 };
330
331 let datadir_cli_path = paths::devcontainer_dir()
332 .join("node_modules")
333 .join("@devcontainers")
334 .join("cli")
335 .join(&dev_container_script());
336
337 log::debug!(
338 "devcontainer not found in path, using local location: ${}",
339 datadir_cli_path.display()
340 );
341
342 let mut command =
343 util::command::new_command(node_runtime_path.as_os_str().display().to_string());
344 command.arg(datadir_cli_path.display().to_string());
345 command.arg("--version");
346
347 match command.output().await {
348 Err(e) => log::error!(
349 "Unable to find devcontainer CLI in Data dir. Will try to install. Error: {:?}",
350 e
351 ),
352 Ok(output) => {
353 if output.status.success() {
354 log::info!("Found devcontainer CLI in Data dir");
355 return Ok(DevContainerCli {
356 path: datadir_cli_path.clone(),
357 node_runtime_path: Some(node_runtime_path.clone()),
358 });
359 } else {
360 log::error!(
361 "Could not run devcontainer CLI from data_dir. Will try once more to install. Output: {:?}",
362 output
363 );
364 }
365 }
366 }
367
368 if let Err(e) = fs::create_dir_all(paths::devcontainer_dir()).await {
369 log::error!("Unable to create devcontainer directory. Error: {:?}", e);
370 return Err(DevContainerError::DevContainerCliNotAvailable);
371 }
372
373 if let Err(e) = node_runtime
374 .npm_install_packages(
375 &paths::devcontainer_dir(),
376 &[("@devcontainers/cli", "latest")],
377 )
378 .await
379 {
380 log::error!(
381 "Unable to install devcontainer CLI to data directory. Error: {:?}",
382 e
383 );
384 return Err(DevContainerError::DevContainerCliNotAvailable);
385 };
386
387 let mut command =
388 util::command::new_command(node_runtime_path.as_os_str().display().to_string());
389 command.arg(datadir_cli_path.display().to_string());
390 command.arg("--version");
391 if let Err(e) = command.output().await {
392 log::error!(
393 "Unable to find devcontainer cli after NPM install. Error: {:?}",
394 e
395 );
396 Err(DevContainerError::DevContainerCliNotAvailable)
397 } else {
398 Ok(DevContainerCli {
399 path: datadir_cli_path,
400 node_runtime_path: Some(node_runtime_path),
401 })
402 }
403 } else {
404 log::info!("Found devcontainer cli on $PATH, using it");
405 Ok(DevContainerCli {
406 path: PathBuf::from(&dev_container_cli()),
407 node_runtime_path: None,
408 })
409 }
410}
411
412async fn devcontainer_up(
413 context: &DevContainerContext,
414 cli: &DevContainerCli,
415 config_path: Option<&Path>,
416) -> Result<DevContainerUp, DevContainerError> {
417 let mut command = cli.command(context.use_podman);
418 command.arg("up");
419 command.arg("--workspace-folder");
420 command.arg(context.project_directory.display().to_string());
421
422 if let Some(config) = config_path {
423 command.arg("--config");
424 command.arg(config.display().to_string());
425 }
426
427 log::info!("Running full devcontainer up command: {:?}", command);
428
429 match command.output().await {
430 Ok(output) => {
431 if output.status.success() {
432 let raw = String::from_utf8_lossy(&output.stdout);
433 parse_json_from_cli(&raw)
434 } else {
435 let message = format!(
436 "Non-success status running devcontainer up for workspace: out: {}, err: {}",
437 String::from_utf8_lossy(&output.stdout),
438 String::from_utf8_lossy(&output.stderr)
439 );
440
441 log::error!("{}", &message);
442 Err(DevContainerError::DevContainerUpFailed(message))
443 }
444 }
445 Err(e) => {
446 let message = format!("Error running devcontainer up: {:?}", e);
447 log::error!("{}", &message);
448 Err(DevContainerError::DevContainerUpFailed(message))
449 }
450 }
451}
452
453pub(crate) async fn read_devcontainer_configuration(
454 context: &DevContainerContext,
455 cli: &DevContainerCli,
456 config_path: Option<&Path>,
457) -> Result<DevContainerConfigurationOutput, DevContainerError> {
458 let mut command = cli.command(context.use_podman);
459 command.arg("read-configuration");
460 command.arg("--workspace-folder");
461 command.arg(context.project_directory.display().to_string());
462
463 if let Some(config) = config_path {
464 command.arg("--config");
465 command.arg(config.display().to_string());
466 }
467
468 match command.output().await {
469 Ok(output) => {
470 if output.status.success() {
471 let raw = String::from_utf8_lossy(&output.stdout);
472 parse_json_from_cli(&raw)
473 } else {
474 let message = format!(
475 "Non-success status running devcontainer read-configuration for workspace: out: {:?}, err: {:?}",
476 String::from_utf8_lossy(&output.stdout),
477 String::from_utf8_lossy(&output.stderr)
478 );
479 log::error!("{}", &message);
480 Err(DevContainerError::DevContainerNotFound)
481 }
482 }
483 Err(e) => {
484 let message = format!("Error running devcontainer read-configuration: {:?}", e);
485 log::error!("{}", &message);
486 Err(DevContainerError::DevContainerNotFound)
487 }
488 }
489}
490
491pub(crate) async fn apply_dev_container_template(
492 template: &DevContainerTemplate,
493 template_options: &HashMap<String, String>,
494 features_selected: &HashSet<DevContainerFeature>,
495 context: &DevContainerContext,
496 cli: &DevContainerCli,
497) -> Result<DevContainerApply, DevContainerError> {
498 let mut command = cli.command(context.use_podman);
499
500 let Ok(serialized_options) = serde_json::to_string(template_options) else {
501 log::error!("Unable to serialize options for {:?}", template_options);
502 return Err(DevContainerError::DevContainerParseFailed);
503 };
504
505 command.arg("templates");
506 command.arg("apply");
507 command.arg("--workspace-folder");
508 command.arg(context.project_directory.display().to_string());
509 command.arg("--template-id");
510 command.arg(format!(
511 "{}/{}",
512 template
513 .source_repository
514 .as_ref()
515 .unwrap_or(&String::from("")),
516 template.id
517 ));
518 command.arg("--template-args");
519 command.arg(serialized_options);
520 command.arg("--features");
521 command.arg(template_features_to_json(features_selected));
522
523 log::debug!("Running full devcontainer apply command: {:?}", command);
524
525 match command.output().await {
526 Ok(output) => {
527 if output.status.success() {
528 let raw = String::from_utf8_lossy(&output.stdout);
529 parse_json_from_cli(&raw)
530 } else {
531 let message = format!(
532 "Non-success status running devcontainer templates apply for workspace: out: {:?}, err: {:?}",
533 String::from_utf8_lossy(&output.stdout),
534 String::from_utf8_lossy(&output.stderr)
535 );
536
537 log::error!("{}", &message);
538 Err(DevContainerError::DevContainerTemplateApplyFailed(message))
539 }
540 }
541 Err(e) => {
542 let message = format!("Error running devcontainer templates apply: {:?}", e);
543 log::error!("{}", &message);
544 Err(DevContainerError::DevContainerTemplateApplyFailed(message))
545 }
546 }
547}
548// Try to parse directly first (newer versions output pure JSON)
549// If that fails, look for JSON start (older versions have plaintext prefix)
550fn parse_json_from_cli<T: serde::de::DeserializeOwned>(raw: &str) -> Result<T, DevContainerError> {
551 serde_json::from_str::<T>(&raw)
552 .or_else(|e| {
553 log::error!("Error parsing json: {} - will try to find json object in larger plaintext", e);
554 let json_start = raw
555 .find(|c| c == '{')
556 .ok_or_else(|| {
557 log::error!("No JSON found in devcontainer up output");
558 DevContainerError::DevContainerParseFailed
559 })?;
560
561 serde_json::from_str(&raw[json_start..]).map_err(|e| {
562 log::error!(
563 "Unable to parse JSON from devcontainer up output (starting at position {}), error: {:?}",
564 json_start,
565 e
566 );
567 DevContainerError::DevContainerParseFailed
568 })
569 })
570}
571
572fn get_backup_project_name(remote_workspace_folder: &str, container_id: &str) -> String {
573 Path::new(remote_workspace_folder)
574 .file_name()
575 .and_then(|name| name.to_str())
576 .map(|string| string.to_string())
577 .unwrap_or_else(|| container_id.to_string())
578}
579
580fn template_features_to_json(features_selected: &HashSet<DevContainerFeature>) -> String {
581 let features_map = features_selected
582 .iter()
583 .map(|feature| {
584 let mut map = HashMap::new();
585 map.insert(
586 "id",
587 format!(
588 "{}/{}:{}",
589 feature
590 .source_repository
591 .as_ref()
592 .unwrap_or(&String::from("")),
593 feature.id,
594 feature.major_version()
595 ),
596 );
597 map
598 })
599 .collect::<Vec<HashMap<&str, String>>>();
600 serde_json::to_string(&features_map).unwrap()
601}
602
603#[cfg(test)]
604mod tests {
605 use std::path::PathBuf;
606
607 use crate::devcontainer_api::{
608 DevContainerConfig, DevContainerUp, find_configs_in_snapshot, parse_json_from_cli,
609 };
610 use fs::FakeFs;
611 use gpui::TestAppContext;
612 use project::Project;
613 use serde_json::json;
614 use settings::SettingsStore;
615 use util::path;
616
617 fn init_test(cx: &mut TestAppContext) {
618 cx.update(|cx| {
619 let settings_store = SettingsStore::test(cx);
620 cx.set_global(settings_store);
621 });
622 }
623
624 #[test]
625 fn should_parse_from_devcontainer_json() {
626 let json = r#"{"outcome":"success","containerId":"826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/zed"}"#;
627 let up: DevContainerUp = parse_json_from_cli(json).unwrap();
628 assert_eq!(up._outcome, "success");
629 assert_eq!(
630 up.container_id,
631 "826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a"
632 );
633 assert_eq!(up.remote_user, "vscode");
634 assert_eq!(up.remote_workspace_folder, "/workspaces/zed");
635
636 let json_in_plaintext = r#"[2026-01-22T16:19:08.802Z] @devcontainers/cli 0.80.1. Node.js v22.21.1. darwin 24.6.0 arm64.
637 {"outcome":"success","containerId":"826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/zed"}"#;
638 let up: DevContainerUp = parse_json_from_cli(json_in_plaintext).unwrap();
639 assert_eq!(up._outcome, "success");
640 assert_eq!(
641 up.container_id,
642 "826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a"
643 );
644 assert_eq!(up.remote_user, "vscode");
645 assert_eq!(up.remote_workspace_folder, "/workspaces/zed");
646 }
647
648 #[gpui::test]
649 async fn test_find_configs_root_devcontainer_json(cx: &mut TestAppContext) {
650 init_test(cx);
651 let fs = FakeFs::new(cx.executor());
652 fs.insert_tree(
653 path!("/project"),
654 json!({
655 ".devcontainer.json": "{}"
656 }),
657 )
658 .await;
659
660 let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
661 cx.run_until_parked();
662
663 let configs = project.read_with(cx, |project, cx| {
664 let worktree = project
665 .visible_worktrees(cx)
666 .next()
667 .expect("should have a worktree");
668 find_configs_in_snapshot(worktree.read(cx))
669 });
670
671 assert_eq!(configs.len(), 1);
672 assert_eq!(configs[0].name, "root");
673 assert_eq!(configs[0].config_path, PathBuf::from(".devcontainer.json"));
674 }
675
676 #[gpui::test]
677 async fn test_find_configs_default_devcontainer_dir(cx: &mut TestAppContext) {
678 init_test(cx);
679 let fs = FakeFs::new(cx.executor());
680 fs.insert_tree(
681 path!("/project"),
682 json!({
683 ".devcontainer": {
684 "devcontainer.json": "{}"
685 }
686 }),
687 )
688 .await;
689
690 let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
691 cx.run_until_parked();
692
693 let configs = project.read_with(cx, |project, cx| {
694 let worktree = project
695 .visible_worktrees(cx)
696 .next()
697 .expect("should have a worktree");
698 find_configs_in_snapshot(worktree.read(cx))
699 });
700
701 assert_eq!(configs.len(), 1);
702 assert_eq!(configs[0], DevContainerConfig::default_config());
703 }
704
705 #[gpui::test]
706 async fn test_find_configs_dir_and_root_both_included(cx: &mut TestAppContext) {
707 init_test(cx);
708 let fs = FakeFs::new(cx.executor());
709 fs.insert_tree(
710 path!("/project"),
711 json!({
712 ".devcontainer.json": "{}",
713 ".devcontainer": {
714 "devcontainer.json": "{}"
715 }
716 }),
717 )
718 .await;
719
720 let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
721 cx.run_until_parked();
722
723 let configs = project.read_with(cx, |project, cx| {
724 let worktree = project
725 .visible_worktrees(cx)
726 .next()
727 .expect("should have a worktree");
728 find_configs_in_snapshot(worktree.read(cx))
729 });
730
731 assert_eq!(configs.len(), 2);
732 assert_eq!(configs[0], DevContainerConfig::default_config());
733 assert_eq!(configs[1], DevContainerConfig::root_config());
734 }
735
736 #[gpui::test]
737 async fn test_find_configs_subfolder_configs(cx: &mut TestAppContext) {
738 init_test(cx);
739 let fs = FakeFs::new(cx.executor());
740 fs.insert_tree(
741 path!("/project"),
742 json!({
743 ".devcontainer": {
744 "rust": {
745 "devcontainer.json": "{}"
746 },
747 "python": {
748 "devcontainer.json": "{}"
749 }
750 }
751 }),
752 )
753 .await;
754
755 let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
756 cx.run_until_parked();
757
758 let configs = project.read_with(cx, |project, cx| {
759 let worktree = project
760 .visible_worktrees(cx)
761 .next()
762 .expect("should have a worktree");
763 find_configs_in_snapshot(worktree.read(cx))
764 });
765
766 assert_eq!(configs.len(), 2);
767 let names: Vec<&str> = configs.iter().map(|c| c.name.as_str()).collect();
768 assert!(names.contains(&"python"));
769 assert!(names.contains(&"rust"));
770 }
771
772 #[gpui::test]
773 async fn test_find_configs_default_and_subfolder(cx: &mut TestAppContext) {
774 init_test(cx);
775 let fs = FakeFs::new(cx.executor());
776 fs.insert_tree(
777 path!("/project"),
778 json!({
779 ".devcontainer": {
780 "devcontainer.json": "{}",
781 "gpu": {
782 "devcontainer.json": "{}"
783 }
784 }
785 }),
786 )
787 .await;
788
789 let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
790 cx.run_until_parked();
791
792 let configs = project.read_with(cx, |project, cx| {
793 let worktree = project
794 .visible_worktrees(cx)
795 .next()
796 .expect("should have a worktree");
797 find_configs_in_snapshot(worktree.read(cx))
798 });
799
800 assert_eq!(configs.len(), 2);
801 assert_eq!(configs[0].name, "default");
802 assert_eq!(configs[1].name, "gpu");
803 }
804
805 #[gpui::test]
806 async fn test_find_configs_no_devcontainer(cx: &mut TestAppContext) {
807 init_test(cx);
808 let fs = FakeFs::new(cx.executor());
809 fs.insert_tree(
810 path!("/project"),
811 json!({
812 "src": {
813 "main.rs": "fn main() {}"
814 }
815 }),
816 )
817 .await;
818
819 let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
820 cx.run_until_parked();
821
822 let configs = project.read_with(cx, |project, cx| {
823 let worktree = project
824 .visible_worktrees(cx)
825 .next()
826 .expect("should have a worktree");
827 find_configs_in_snapshot(worktree.read(cx))
828 });
829
830 assert!(configs.is_empty());
831 }
832
833 #[gpui::test]
834 async fn test_find_configs_root_json_and_subfolder_configs(cx: &mut TestAppContext) {
835 init_test(cx);
836 let fs = FakeFs::new(cx.executor());
837 fs.insert_tree(
838 path!("/project"),
839 json!({
840 ".devcontainer.json": "{}",
841 ".devcontainer": {
842 "rust": {
843 "devcontainer.json": "{}"
844 }
845 }
846 }),
847 )
848 .await;
849
850 let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
851 cx.run_until_parked();
852
853 let configs = project.read_with(cx, |project, cx| {
854 let worktree = project
855 .visible_worktrees(cx)
856 .next()
857 .expect("should have a worktree");
858 find_configs_in_snapshot(worktree.read(cx))
859 });
860
861 assert_eq!(configs.len(), 2);
862 assert_eq!(configs[0].name, "root");
863 assert_eq!(configs[0].config_path, PathBuf::from(".devcontainer.json"));
864 assert_eq!(configs[1].name, "rust");
865 assert_eq!(
866 configs[1].config_path,
867 PathBuf::from(".devcontainer/rust/devcontainer.json")
868 );
869 }
870
871 #[gpui::test]
872 async fn test_find_configs_empty_devcontainer_dir_falls_back_to_root(cx: &mut TestAppContext) {
873 init_test(cx);
874 let fs = FakeFs::new(cx.executor());
875 fs.insert_tree(
876 path!("/project"),
877 json!({
878 ".devcontainer.json": "{}",
879 ".devcontainer": {}
880 }),
881 )
882 .await;
883
884 let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
885 cx.run_until_parked();
886
887 let configs = project.read_with(cx, |project, cx| {
888 let worktree = project
889 .visible_worktrees(cx)
890 .next()
891 .expect("should have a worktree");
892 find_configs_in_snapshot(worktree.read(cx))
893 });
894
895 assert_eq!(configs.len(), 1);
896 assert_eq!(configs[0], DevContainerConfig::root_config());
897 }
898}