1use std::{
2 collections::{HashMap, HashSet},
3 fmt::Display,
4 path::{Path, PathBuf},
5 sync::Arc,
6};
7
8use gpui::AsyncWindowContext;
9use node_runtime::NodeRuntime;
10use serde::Deserialize;
11use settings::{DevContainerConnection, Settings as _};
12use smol::{fs, process::Command};
13use util::rel_path::RelPath;
14use workspace::Workspace;
15
16use crate::{DevContainerFeature, DevContainerSettings, 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
36#[derive(Debug, Deserialize)]
37#[serde(rename_all = "camelCase")]
38struct DevContainerUp {
39 _outcome: String,
40 container_id: String,
41 remote_user: String,
42 remote_workspace_folder: String,
43}
44
45#[derive(Debug, Deserialize)]
46#[serde(rename_all = "camelCase")]
47pub(crate) struct DevContainerApply {
48 pub(crate) files: Vec<String>,
49}
50
51#[derive(Debug, Deserialize)]
52#[serde(rename_all = "camelCase")]
53pub(crate) struct DevContainerConfiguration {
54 name: Option<String>,
55}
56
57#[derive(Debug, Deserialize)]
58pub(crate) struct DevContainerConfigurationOutput {
59 configuration: DevContainerConfiguration,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum DevContainerError {
64 DockerNotAvailable,
65 DevContainerCliNotAvailable,
66 DevContainerTemplateApplyFailed(String),
67 DevContainerUpFailed(String),
68 DevContainerNotFound,
69 DevContainerParseFailed,
70 NodeRuntimeNotAvailable,
71 NotInValidProject,
72}
73
74impl Display for DevContainerError {
75 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 write!(
77 f,
78 "{}",
79 match self {
80 DevContainerError::DockerNotAvailable =>
81 "Docker CLI not found on $PATH".to_string(),
82 DevContainerError::DevContainerCliNotAvailable =>
83 "Docker not found on path".to_string(),
84 DevContainerError::DevContainerUpFailed(message) => {
85 format!("DevContainer creation failed with error: {}", message)
86 }
87 DevContainerError::DevContainerTemplateApplyFailed(message) => {
88 format!("DevContainer template apply failed with error: {}", message)
89 }
90 DevContainerError::DevContainerNotFound =>
91 "No valid dev container definition found in project".to_string(),
92 DevContainerError::DevContainerParseFailed =>
93 "Failed to parse file .devcontainer/devcontainer.json".to_string(),
94 DevContainerError::NodeRuntimeNotAvailable =>
95 "Cannot find a valid node runtime".to_string(),
96 DevContainerError::NotInValidProject => "Not within a valid project".to_string(),
97 }
98 )
99 }
100}
101
102pub(crate) async fn read_devcontainer_configuration_for_project(
103 cx: &mut AsyncWindowContext,
104 node_runtime: &NodeRuntime,
105) -> Result<DevContainerConfigurationOutput, DevContainerError> {
106 let (path_to_devcontainer_cli, found_in_path) = ensure_devcontainer_cli(&node_runtime).await?;
107
108 let Some(directory) = project_directory(cx) else {
109 return Err(DevContainerError::NotInValidProject);
110 };
111
112 devcontainer_read_configuration(
113 &path_to_devcontainer_cli,
114 found_in_path,
115 node_runtime,
116 &directory,
117 None,
118 use_podman(cx),
119 )
120 .await
121}
122
123pub(crate) async fn apply_dev_container_template(
124 template: &DevContainerTemplate,
125 options_selected: &HashMap<String, String>,
126 features_selected: &HashSet<DevContainerFeature>,
127 cx: &mut AsyncWindowContext,
128 node_runtime: &NodeRuntime,
129) -> Result<DevContainerApply, DevContainerError> {
130 let (path_to_devcontainer_cli, found_in_path) = ensure_devcontainer_cli(&node_runtime).await?;
131
132 let Some(directory) = project_directory(cx) else {
133 return Err(DevContainerError::NotInValidProject);
134 };
135
136 devcontainer_template_apply(
137 template,
138 options_selected,
139 features_selected,
140 &path_to_devcontainer_cli,
141 found_in_path,
142 node_runtime,
143 &directory,
144 false, // devcontainer template apply does not use --docker-path option
145 )
146 .await
147}
148
149fn use_podman(cx: &mut AsyncWindowContext) -> bool {
150 cx.update(|_, cx| DevContainerSettings::get_global(cx).use_podman)
151 .unwrap_or(false)
152}
153
154/// Finds all available devcontainer configurations in the project.
155///
156/// This function scans for:
157/// 1. `.devcontainer/devcontainer.json` (the default location)
158/// 2. `.devcontainer/<subfolder>/devcontainer.json` (named configurations)
159///
160/// Returns a list of found configurations, or an empty list if none are found.
161pub fn find_devcontainer_configs(cx: &mut AsyncWindowContext) -> Vec<DevContainerConfig> {
162 let Some(workspace) = cx.window_handle().downcast::<Workspace>() else {
163 log::debug!("find_devcontainer_configs: No workspace found");
164 return Vec::new();
165 };
166
167 let Ok(configs) = workspace.update(cx, |workspace, _, cx| {
168 let project = workspace.project().read(cx);
169
170 let worktree = project
171 .visible_worktrees(cx)
172 .find_map(|tree| tree.read(cx).root_entry()?.is_dir().then_some(tree));
173
174 let Some(worktree) = worktree else {
175 log::debug!("find_devcontainer_configs: No worktree found");
176 return Vec::new();
177 };
178
179 let worktree = worktree.read(cx);
180 let mut configs = Vec::new();
181
182 let devcontainer_path = RelPath::unix(".devcontainer").expect("valid path");
183
184 let Some(devcontainer_entry) = worktree.entry_for_path(devcontainer_path) else {
185 log::debug!("find_devcontainer_configs: .devcontainer directory not found in worktree");
186 return Vec::new();
187 };
188
189 if !devcontainer_entry.is_dir() {
190 log::debug!("find_devcontainer_configs: .devcontainer is not a directory");
191 return Vec::new();
192 }
193
194 log::debug!("find_devcontainer_configs: Scanning .devcontainer directory");
195 let devcontainer_json_path =
196 RelPath::unix(".devcontainer/devcontainer.json").expect("valid path");
197 for entry in worktree.child_entries(devcontainer_path) {
198 log::debug!(
199 "find_devcontainer_configs: Found entry: {:?}, is_file: {}, is_dir: {}",
200 entry.path.as_unix_str(),
201 entry.is_file(),
202 entry.is_dir()
203 );
204
205 if entry.is_file() && entry.path.as_ref() == devcontainer_json_path {
206 log::debug!("find_devcontainer_configs: Found default devcontainer.json");
207 configs.push(DevContainerConfig::default_config());
208 } else if entry.is_dir() {
209 let subfolder_name = entry
210 .path
211 .file_name()
212 .map(|n| n.to_string())
213 .unwrap_or_default();
214
215 let config_json_path = format!("{}/devcontainer.json", entry.path.as_unix_str());
216 if let Ok(rel_config_path) = RelPath::unix(&config_json_path) {
217 if worktree.entry_for_path(rel_config_path).is_some() {
218 log::debug!(
219 "find_devcontainer_configs: Found config in subfolder: {}",
220 subfolder_name
221 );
222 configs.push(DevContainerConfig {
223 name: subfolder_name,
224 config_path: PathBuf::from(&config_json_path),
225 });
226 } else {
227 log::debug!(
228 "find_devcontainer_configs: Subfolder {} has no devcontainer.json",
229 subfolder_name
230 );
231 }
232 }
233 }
234 }
235
236 log::info!(
237 "find_devcontainer_configs: Found {} configurations",
238 configs.len()
239 );
240
241 configs.sort_by(|a, b| {
242 if a.name == "default" {
243 std::cmp::Ordering::Less
244 } else if b.name == "default" {
245 std::cmp::Ordering::Greater
246 } else {
247 a.name.cmp(&b.name)
248 }
249 });
250
251 configs
252 }) else {
253 log::debug!("find_devcontainer_configs: Failed to update workspace");
254 return Vec::new();
255 };
256
257 configs
258}
259
260pub async fn start_dev_container(
261 cx: &mut AsyncWindowContext,
262 node_runtime: NodeRuntime,
263) -> Result<(DevContainerConnection, String), DevContainerError> {
264 start_dev_container_with_config(cx, node_runtime, None).await
265}
266
267pub async fn start_dev_container_with_config(
268 cx: &mut AsyncWindowContext,
269 node_runtime: NodeRuntime,
270 config: Option<DevContainerConfig>,
271) -> Result<(DevContainerConnection, String), DevContainerError> {
272 let use_podman = use_podman(cx);
273 check_for_docker(use_podman).await?;
274
275 let (path_to_devcontainer_cli, found_in_path) = ensure_devcontainer_cli(&node_runtime).await?;
276
277 let Some(directory) = project_directory(cx) else {
278 return Err(DevContainerError::NotInValidProject);
279 };
280
281 let config_path = config.map(|c| directory.join(&c.config_path));
282
283 match devcontainer_up(
284 &path_to_devcontainer_cli,
285 found_in_path,
286 &node_runtime,
287 directory.clone(),
288 config_path.clone(),
289 use_podman,
290 )
291 .await
292 {
293 Ok(DevContainerUp {
294 container_id,
295 remote_workspace_folder,
296 remote_user,
297 ..
298 }) => {
299 let project_name = match devcontainer_read_configuration(
300 &path_to_devcontainer_cli,
301 found_in_path,
302 &node_runtime,
303 &directory,
304 config_path.as_ref(),
305 use_podman,
306 )
307 .await
308 {
309 Ok(DevContainerConfigurationOutput {
310 configuration:
311 DevContainerConfiguration {
312 name: Some(project_name),
313 },
314 }) => project_name,
315 _ => get_backup_project_name(&remote_workspace_folder, &container_id),
316 };
317
318 let connection = DevContainerConnection {
319 name: project_name,
320 container_id: container_id,
321 use_podman,
322 remote_user,
323 };
324
325 Ok((connection, remote_workspace_folder))
326 }
327 Err(err) => {
328 let message = format!("Failed with nested error: {}", err);
329 Err(DevContainerError::DevContainerUpFailed(message))
330 }
331 }
332}
333
334#[cfg(not(target_os = "windows"))]
335fn dev_container_cli() -> String {
336 "devcontainer".to_string()
337}
338
339#[cfg(target_os = "windows")]
340fn dev_container_cli() -> String {
341 "devcontainer.cmd".to_string()
342}
343
344async fn check_for_docker(use_podman: bool) -> Result<(), DevContainerError> {
345 let mut command = if use_podman {
346 util::command::new_smol_command("podman")
347 } else {
348 util::command::new_smol_command("docker")
349 };
350 command.arg("--version");
351
352 match command.output().await {
353 Ok(_) => Ok(()),
354 Err(e) => {
355 log::error!("Unable to find docker in $PATH: {:?}", e);
356 Err(DevContainerError::DockerNotAvailable)
357 }
358 }
359}
360
361async fn ensure_devcontainer_cli(
362 node_runtime: &NodeRuntime,
363) -> Result<(PathBuf, bool), DevContainerError> {
364 let mut command = util::command::new_smol_command(&dev_container_cli());
365 command.arg("--version");
366
367 if let Err(e) = command.output().await {
368 log::error!(
369 "Unable to find devcontainer CLI in $PATH. Checking for a zed installed version. Error: {:?}",
370 e
371 );
372
373 let Ok(node_runtime_path) = node_runtime.binary_path().await else {
374 return Err(DevContainerError::NodeRuntimeNotAvailable);
375 };
376
377 let datadir_cli_path = paths::devcontainer_dir()
378 .join("node_modules")
379 .join("@devcontainers")
380 .join("cli")
381 .join(format!("{}.js", &dev_container_cli()));
382
383 log::debug!(
384 "devcontainer not found in path, using local location: ${}",
385 datadir_cli_path.display()
386 );
387
388 let mut command =
389 util::command::new_smol_command(node_runtime_path.as_os_str().display().to_string());
390 command.arg(datadir_cli_path.display().to_string());
391 command.arg("--version");
392
393 match command.output().await {
394 Err(e) => log::error!(
395 "Unable to find devcontainer CLI in Data dir. Will try to install. Error: {:?}",
396 e
397 ),
398 Ok(output) => {
399 if output.status.success() {
400 log::info!("Found devcontainer CLI in Data dir");
401 return Ok((datadir_cli_path.clone(), false));
402 } else {
403 log::error!(
404 "Could not run devcontainer CLI from data_dir. Will try once more to install. Output: {:?}",
405 output
406 );
407 }
408 }
409 }
410
411 if let Err(e) = fs::create_dir_all(paths::devcontainer_dir()).await {
412 log::error!("Unable to create devcontainer directory. Error: {:?}", e);
413 return Err(DevContainerError::DevContainerCliNotAvailable);
414 }
415
416 if let Err(e) = node_runtime
417 .npm_install_packages(
418 &paths::devcontainer_dir(),
419 &[("@devcontainers/cli", "latest")],
420 )
421 .await
422 {
423 log::error!(
424 "Unable to install devcontainer CLI to data directory. Error: {:?}",
425 e
426 );
427 return Err(DevContainerError::DevContainerCliNotAvailable);
428 };
429
430 let mut command =
431 util::command::new_smol_command(node_runtime_path.as_os_str().display().to_string());
432 command.arg(datadir_cli_path.display().to_string());
433 command.arg("--version");
434 if let Err(e) = command.output().await {
435 log::error!(
436 "Unable to find devcontainer cli after NPM install. Error: {:?}",
437 e
438 );
439 Err(DevContainerError::DevContainerCliNotAvailable)
440 } else {
441 Ok((datadir_cli_path, false))
442 }
443 } else {
444 log::info!("Found devcontainer cli on $PATH, using it");
445 Ok((PathBuf::from(&dev_container_cli()), true))
446 }
447}
448
449async fn devcontainer_up(
450 path_to_cli: &PathBuf,
451 found_in_path: bool,
452 node_runtime: &NodeRuntime,
453 path: Arc<Path>,
454 config_path: Option<PathBuf>,
455 use_podman: bool,
456) -> Result<DevContainerUp, DevContainerError> {
457 let Ok(node_runtime_path) = node_runtime.binary_path().await else {
458 log::error!("Unable to find node runtime path");
459 return Err(DevContainerError::NodeRuntimeNotAvailable);
460 };
461
462 let mut command =
463 devcontainer_cli_command(path_to_cli, found_in_path, &node_runtime_path, use_podman);
464 command.arg("up");
465 command.arg("--workspace-folder");
466 command.arg(path.display().to_string());
467
468 if let Some(config) = config_path {
469 command.arg("--config");
470 command.arg(config.display().to_string());
471 }
472
473 log::info!("Running full devcontainer up command: {:?}", command);
474
475 match command.output().await {
476 Ok(output) => {
477 if output.status.success() {
478 let raw = String::from_utf8_lossy(&output.stdout);
479 parse_json_from_cli(&raw)
480 } else {
481 let message = format!(
482 "Non-success status running devcontainer up for workspace: out: {:?}, err: {:?}",
483 String::from_utf8_lossy(&output.stdout),
484 String::from_utf8_lossy(&output.stderr)
485 );
486
487 log::error!("{}", &message);
488 Err(DevContainerError::DevContainerUpFailed(message))
489 }
490 }
491 Err(e) => {
492 let message = format!("Error running devcontainer up: {:?}", e);
493 log::error!("{}", &message);
494 Err(DevContainerError::DevContainerUpFailed(message))
495 }
496 }
497}
498
499async fn devcontainer_read_configuration(
500 path_to_cli: &PathBuf,
501 found_in_path: bool,
502 node_runtime: &NodeRuntime,
503 path: &Arc<Path>,
504 config_path: Option<&PathBuf>,
505 use_podman: bool,
506) -> Result<DevContainerConfigurationOutput, DevContainerError> {
507 let Ok(node_runtime_path) = node_runtime.binary_path().await else {
508 log::error!("Unable to find node runtime path");
509 return Err(DevContainerError::NodeRuntimeNotAvailable);
510 };
511
512 let mut command =
513 devcontainer_cli_command(path_to_cli, found_in_path, &node_runtime_path, use_podman);
514 command.arg("read-configuration");
515 command.arg("--workspace-folder");
516 command.arg(path.display().to_string());
517
518 if let Some(config) = config_path {
519 command.arg("--config");
520 command.arg(config.display().to_string());
521 }
522
523 match command.output().await {
524 Ok(output) => {
525 if output.status.success() {
526 let raw = String::from_utf8_lossy(&output.stdout);
527 parse_json_from_cli(&raw)
528 } else {
529 let message = format!(
530 "Non-success status running devcontainer read-configuration for workspace: out: {:?}, err: {:?}",
531 String::from_utf8_lossy(&output.stdout),
532 String::from_utf8_lossy(&output.stderr)
533 );
534 log::error!("{}", &message);
535 Err(DevContainerError::DevContainerNotFound)
536 }
537 }
538 Err(e) => {
539 let message = format!("Error running devcontainer read-configuration: {:?}", e);
540 log::error!("{}", &message);
541 Err(DevContainerError::DevContainerNotFound)
542 }
543 }
544}
545
546async fn devcontainer_template_apply(
547 template: &DevContainerTemplate,
548 template_options: &HashMap<String, String>,
549 features_selected: &HashSet<DevContainerFeature>,
550 path_to_cli: &PathBuf,
551 found_in_path: bool,
552 node_runtime: &NodeRuntime,
553 path: &Arc<Path>,
554 use_podman: bool,
555) -> Result<DevContainerApply, DevContainerError> {
556 let Ok(node_runtime_path) = node_runtime.binary_path().await else {
557 log::error!("Unable to find node runtime path");
558 return Err(DevContainerError::NodeRuntimeNotAvailable);
559 };
560
561 let mut command =
562 devcontainer_cli_command(path_to_cli, found_in_path, &node_runtime_path, use_podman);
563
564 let Ok(serialized_options) = serde_json::to_string(template_options) else {
565 log::error!("Unable to serialize options for {:?}", template_options);
566 return Err(DevContainerError::DevContainerParseFailed);
567 };
568
569 command.arg("templates");
570 command.arg("apply");
571 command.arg("--workspace-folder");
572 command.arg(path.display().to_string());
573 command.arg("--template-id");
574 command.arg(format!(
575 "{}/{}",
576 template
577 .source_repository
578 .as_ref()
579 .unwrap_or(&String::from("")),
580 template.id
581 ));
582 command.arg("--template-args");
583 command.arg(serialized_options);
584 command.arg("--features");
585 command.arg(template_features_to_json(features_selected));
586
587 log::debug!("Running full devcontainer apply command: {:?}", command);
588
589 match command.output().await {
590 Ok(output) => {
591 if output.status.success() {
592 let raw = String::from_utf8_lossy(&output.stdout);
593 parse_json_from_cli(&raw)
594 } else {
595 let message = format!(
596 "Non-success status running devcontainer templates apply for workspace: out: {:?}, err: {:?}",
597 String::from_utf8_lossy(&output.stdout),
598 String::from_utf8_lossy(&output.stderr)
599 );
600
601 log::error!("{}", &message);
602 Err(DevContainerError::DevContainerTemplateApplyFailed(message))
603 }
604 }
605 Err(e) => {
606 let message = format!("Error running devcontainer templates apply: {:?}", e);
607 log::error!("{}", &message);
608 Err(DevContainerError::DevContainerTemplateApplyFailed(message))
609 }
610 }
611}
612// Try to parse directly first (newer versions output pure JSON)
613// If that fails, look for JSON start (older versions have plaintext prefix)
614fn parse_json_from_cli<T: serde::de::DeserializeOwned>(raw: &str) -> Result<T, DevContainerError> {
615 serde_json::from_str::<T>(&raw)
616 .or_else(|e| {
617 log::error!("Error parsing json: {} - will try to find json object in larger plaintext", e);
618 let json_start = raw
619 .find(|c| c == '{')
620 .ok_or_else(|| {
621 log::error!("No JSON found in devcontainer up output");
622 DevContainerError::DevContainerParseFailed
623 })?;
624
625 serde_json::from_str(&raw[json_start..]).map_err(|e| {
626 log::error!(
627 "Unable to parse JSON from devcontainer up output (starting at position {}), error: {:?}",
628 json_start,
629 e
630 );
631 DevContainerError::DevContainerParseFailed
632 })
633 })
634}
635
636fn devcontainer_cli_command(
637 path_to_cli: &PathBuf,
638 found_in_path: bool,
639 node_runtime_path: &PathBuf,
640 use_podman: bool,
641) -> Command {
642 let mut command = if found_in_path {
643 util::command::new_smol_command(path_to_cli.display().to_string())
644 } else {
645 let mut command =
646 util::command::new_smol_command(node_runtime_path.as_os_str().display().to_string());
647 command.arg(path_to_cli.display().to_string());
648 command
649 };
650
651 if use_podman {
652 command.arg("--docker-path");
653 command.arg("podman");
654 }
655 command
656}
657
658fn get_backup_project_name(remote_workspace_folder: &str, container_id: &str) -> String {
659 Path::new(remote_workspace_folder)
660 .file_name()
661 .and_then(|name| name.to_str())
662 .map(|string| string.to_string())
663 .unwrap_or_else(|| container_id.to_string())
664}
665
666fn project_directory(cx: &mut AsyncWindowContext) -> Option<Arc<Path>> {
667 let Some(workspace) = cx.window_handle().downcast::<Workspace>() else {
668 return None;
669 };
670
671 match workspace.update(cx, |workspace, _, cx| {
672 workspace.project().read(cx).active_project_directory(cx)
673 }) {
674 Ok(dir) => dir,
675 Err(e) => {
676 log::error!("Error getting project directory from workspace: {:?}", e);
677 None
678 }
679 }
680}
681
682fn template_features_to_json(features_selected: &HashSet<DevContainerFeature>) -> String {
683 let features_map = features_selected
684 .iter()
685 .map(|feature| {
686 let mut map = HashMap::new();
687 map.insert(
688 "id",
689 format!(
690 "{}/{}:{}",
691 feature
692 .source_repository
693 .as_ref()
694 .unwrap_or(&String::from("")),
695 feature.id,
696 feature.major_version()
697 ),
698 );
699 map
700 })
701 .collect::<Vec<HashMap<&str, String>>>();
702 serde_json::to_string(&features_map).unwrap()
703}
704
705#[cfg(test)]
706mod tests {
707 use crate::devcontainer_api::{DevContainerUp, parse_json_from_cli};
708
709 #[test]
710 fn should_parse_from_devcontainer_json() {
711 let json = r#"{"outcome":"success","containerId":"826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/zed"}"#;
712 let up: DevContainerUp = parse_json_from_cli(json).unwrap();
713 assert_eq!(up._outcome, "success");
714 assert_eq!(
715 up.container_id,
716 "826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a"
717 );
718 assert_eq!(up.remote_user, "vscode");
719 assert_eq!(up.remote_workspace_folder, "/workspaces/zed");
720
721 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.
722 {"outcome":"success","containerId":"826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/zed"}"#;
723 let up: DevContainerUp = parse_json_from_cli(json_in_plaintext).unwrap();
724 assert_eq!(up._outcome, "success");
725 assert_eq!(
726 up.container_id,
727 "826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a"
728 );
729 assert_eq!(up.remote_user, "vscode");
730 assert_eq!(up.remote_workspace_folder, "/workspaces/zed");
731 }
732}