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