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