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}