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 workspace::Workspace;
14
15use crate::{DevContainerFeature, DevContainerSettings, DevContainerTemplate};
16
17#[derive(Debug, Deserialize)]
18#[serde(rename_all = "camelCase")]
19struct DevContainerUp {
20 _outcome: String,
21 container_id: String,
22 _remote_user: String,
23 remote_workspace_folder: String,
24}
25
26#[derive(Debug, Deserialize)]
27#[serde(rename_all = "camelCase")]
28pub(crate) struct DevContainerApply {
29 pub(crate) files: Vec<String>,
30}
31
32#[derive(Debug, Deserialize)]
33#[serde(rename_all = "camelCase")]
34pub(crate) struct DevContainerConfiguration {
35 name: Option<String>,
36}
37
38#[derive(Debug, Deserialize)]
39pub(crate) struct DevContainerConfigurationOutput {
40 configuration: DevContainerConfiguration,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum DevContainerError {
45 DockerNotAvailable,
46 DevContainerCliNotAvailable,
47 DevContainerTemplateApplyFailed(String),
48 DevContainerUpFailed(String),
49 DevContainerNotFound,
50 DevContainerParseFailed,
51 NodeRuntimeNotAvailable,
52 NotInValidProject,
53}
54
55impl Display for DevContainerError {
56 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 write!(
58 f,
59 "{}",
60 match self {
61 DevContainerError::DockerNotAvailable =>
62 "Docker CLI not found on $PATH".to_string(),
63 DevContainerError::DevContainerCliNotAvailable =>
64 "Docker not found on path".to_string(),
65 DevContainerError::DevContainerUpFailed(message) => {
66 format!("DevContainer creation failed with error: {}", message)
67 }
68 DevContainerError::DevContainerTemplateApplyFailed(message) => {
69 format!("DevContainer template apply failed with error: {}", message)
70 }
71 DevContainerError::DevContainerNotFound =>
72 "No valid dev container definition found in project".to_string(),
73 DevContainerError::DevContainerParseFailed =>
74 "Failed to parse file .devcontainer/devcontainer.json".to_string(),
75 DevContainerError::NodeRuntimeNotAvailable =>
76 "Cannot find a valid node runtime".to_string(),
77 DevContainerError::NotInValidProject => "Not within a valid project".to_string(),
78 }
79 )
80 }
81}
82
83pub(crate) async fn read_devcontainer_configuration_for_project(
84 cx: &mut AsyncWindowContext,
85 node_runtime: &NodeRuntime,
86) -> Result<DevContainerConfigurationOutput, DevContainerError> {
87 let (path_to_devcontainer_cli, found_in_path) = ensure_devcontainer_cli(&node_runtime).await?;
88
89 let Some(directory) = project_directory(cx) else {
90 return Err(DevContainerError::NotInValidProject);
91 };
92
93 devcontainer_read_configuration(
94 &path_to_devcontainer_cli,
95 found_in_path,
96 node_runtime,
97 &directory,
98 use_podman(cx),
99 )
100 .await
101}
102
103pub(crate) async fn apply_dev_container_template(
104 template: &DevContainerTemplate,
105 options_selected: &HashMap<String, String>,
106 features_selected: &HashSet<DevContainerFeature>,
107 cx: &mut AsyncWindowContext,
108 node_runtime: &NodeRuntime,
109) -> Result<DevContainerApply, DevContainerError> {
110 let (path_to_devcontainer_cli, found_in_path) = ensure_devcontainer_cli(&node_runtime).await?;
111
112 let Some(directory) = project_directory(cx) else {
113 return Err(DevContainerError::NotInValidProject);
114 };
115
116 devcontainer_template_apply(
117 template,
118 options_selected,
119 features_selected,
120 &path_to_devcontainer_cli,
121 found_in_path,
122 node_runtime,
123 &directory,
124 false, // devcontainer template apply does not use --docker-path option
125 )
126 .await
127}
128
129fn use_podman(cx: &mut AsyncWindowContext) -> bool {
130 cx.update(|_, cx| DevContainerSettings::get_global(cx).use_podman)
131 .unwrap_or(false)
132}
133
134pub async fn start_dev_container(
135 cx: &mut AsyncWindowContext,
136 node_runtime: NodeRuntime,
137) -> Result<(DevContainerConnection, String), DevContainerError> {
138 let use_podman = use_podman(cx);
139 check_for_docker(use_podman).await?;
140
141 let (path_to_devcontainer_cli, found_in_path) = ensure_devcontainer_cli(&node_runtime).await?;
142
143 let Some(directory) = project_directory(cx) else {
144 return Err(DevContainerError::NotInValidProject);
145 };
146
147 match devcontainer_up(
148 &path_to_devcontainer_cli,
149 found_in_path,
150 &node_runtime,
151 directory.clone(),
152 use_podman,
153 )
154 .await
155 {
156 Ok(DevContainerUp {
157 container_id,
158 remote_workspace_folder,
159 ..
160 }) => {
161 let project_name = match devcontainer_read_configuration(
162 &path_to_devcontainer_cli,
163 found_in_path,
164 &node_runtime,
165 &directory,
166 use_podman,
167 )
168 .await
169 {
170 Ok(DevContainerConfigurationOutput {
171 configuration:
172 DevContainerConfiguration {
173 name: Some(project_name),
174 },
175 }) => project_name,
176 _ => get_backup_project_name(&remote_workspace_folder, &container_id),
177 };
178
179 let connection = DevContainerConnection {
180 name: project_name,
181 container_id: container_id,
182 use_podman,
183 };
184
185 Ok((connection, remote_workspace_folder))
186 }
187 Err(err) => {
188 let message = format!("Failed with nested error: {}", err);
189 Err(DevContainerError::DevContainerUpFailed(message))
190 }
191 }
192}
193
194#[cfg(not(target_os = "windows"))]
195fn dev_container_cli() -> String {
196 "devcontainer".to_string()
197}
198
199#[cfg(target_os = "windows")]
200fn dev_container_cli() -> String {
201 "devcontainer.cmd".to_string()
202}
203
204async fn check_for_docker(use_podman: bool) -> Result<(), DevContainerError> {
205 let mut command = if use_podman {
206 util::command::new_smol_command("podman")
207 } else {
208 util::command::new_smol_command("docker")
209 };
210 command.arg("--version");
211
212 match command.output().await {
213 Ok(_) => Ok(()),
214 Err(e) => {
215 log::error!("Unable to find docker in $PATH: {:?}", e);
216 Err(DevContainerError::DockerNotAvailable)
217 }
218 }
219}
220
221async fn ensure_devcontainer_cli(
222 node_runtime: &NodeRuntime,
223) -> Result<(PathBuf, bool), DevContainerError> {
224 let mut command = util::command::new_smol_command(&dev_container_cli());
225 command.arg("--version");
226
227 if let Err(e) = command.output().await {
228 log::error!(
229 "Unable to find devcontainer CLI in $PATH. Checking for a zed installed version. Error: {:?}",
230 e
231 );
232
233 let Ok(node_runtime_path) = node_runtime.binary_path().await else {
234 return Err(DevContainerError::NodeRuntimeNotAvailable);
235 };
236
237 let datadir_cli_path = paths::devcontainer_dir()
238 .join("node_modules")
239 .join("@devcontainers")
240 .join("cli")
241 .join(format!("{}.js", &dev_container_cli()));
242
243 log::debug!(
244 "devcontainer not found in path, using local location: ${}",
245 datadir_cli_path.display()
246 );
247
248 let mut command =
249 util::command::new_smol_command(node_runtime_path.as_os_str().display().to_string());
250 command.arg(datadir_cli_path.display().to_string());
251 command.arg("--version");
252
253 match command.output().await {
254 Err(e) => log::error!(
255 "Unable to find devcontainer CLI in Data dir. Will try to install. Error: {:?}",
256 e
257 ),
258 Ok(output) => {
259 if output.status.success() {
260 log::info!("Found devcontainer CLI in Data dir");
261 return Ok((datadir_cli_path.clone(), false));
262 } else {
263 log::error!(
264 "Could not run devcontainer CLI from data_dir. Will try once more to install. Output: {:?}",
265 output
266 );
267 }
268 }
269 }
270
271 if let Err(e) = fs::create_dir_all(paths::devcontainer_dir()).await {
272 log::error!("Unable to create devcontainer directory. Error: {:?}", e);
273 return Err(DevContainerError::DevContainerCliNotAvailable);
274 }
275
276 if let Err(e) = node_runtime
277 .npm_install_packages(
278 &paths::devcontainer_dir(),
279 &[("@devcontainers/cli", "latest")],
280 )
281 .await
282 {
283 log::error!(
284 "Unable to install devcontainer CLI to data directory. Error: {:?}",
285 e
286 );
287 return Err(DevContainerError::DevContainerCliNotAvailable);
288 };
289
290 let mut command =
291 util::command::new_smol_command(node_runtime_path.as_os_str().display().to_string());
292 command.arg(datadir_cli_path.display().to_string());
293 command.arg("--version");
294 if let Err(e) = command.output().await {
295 log::error!(
296 "Unable to find devcontainer cli after NPM install. Error: {:?}",
297 e
298 );
299 Err(DevContainerError::DevContainerCliNotAvailable)
300 } else {
301 Ok((datadir_cli_path, false))
302 }
303 } else {
304 log::info!("Found devcontainer cli on $PATH, using it");
305 Ok((PathBuf::from(&dev_container_cli()), true))
306 }
307}
308
309async fn devcontainer_up(
310 path_to_cli: &PathBuf,
311 found_in_path: bool,
312 node_runtime: &NodeRuntime,
313 path: Arc<Path>,
314 use_podman: bool,
315) -> Result<DevContainerUp, DevContainerError> {
316 let Ok(node_runtime_path) = node_runtime.binary_path().await else {
317 log::error!("Unable to find node runtime path");
318 return Err(DevContainerError::NodeRuntimeNotAvailable);
319 };
320
321 let mut command =
322 devcontainer_cli_command(path_to_cli, found_in_path, &node_runtime_path, use_podman);
323 command.arg("up");
324 command.arg("--workspace-folder");
325 command.arg(path.display().to_string());
326
327 log::info!("Running full devcontainer up command: {:?}", command);
328
329 match command.output().await {
330 Ok(output) => {
331 if output.status.success() {
332 let raw = String::from_utf8_lossy(&output.stdout);
333 parse_json_from_cli(&raw)
334 } else {
335 let message = format!(
336 "Non-success status running devcontainer up for workspace: out: {:?}, err: {:?}",
337 String::from_utf8_lossy(&output.stdout),
338 String::from_utf8_lossy(&output.stderr)
339 );
340
341 log::error!("{}", &message);
342 Err(DevContainerError::DevContainerUpFailed(message))
343 }
344 }
345 Err(e) => {
346 let message = format!("Error running devcontainer up: {:?}", e);
347 log::error!("{}", &message);
348 Err(DevContainerError::DevContainerUpFailed(message))
349 }
350 }
351}
352
353async fn devcontainer_read_configuration(
354 path_to_cli: &PathBuf,
355 found_in_path: bool,
356 node_runtime: &NodeRuntime,
357 path: &Arc<Path>,
358 use_podman: bool,
359) -> Result<DevContainerConfigurationOutput, DevContainerError> {
360 let Ok(node_runtime_path) = node_runtime.binary_path().await else {
361 log::error!("Unable to find node runtime path");
362 return Err(DevContainerError::NodeRuntimeNotAvailable);
363 };
364
365 let mut command =
366 devcontainer_cli_command(path_to_cli, found_in_path, &node_runtime_path, use_podman);
367 command.arg("read-configuration");
368 command.arg("--workspace-folder");
369 command.arg(path.display().to_string());
370
371 match command.output().await {
372 Ok(output) => {
373 if output.status.success() {
374 let raw = String::from_utf8_lossy(&output.stdout);
375 parse_json_from_cli(&raw)
376 } else {
377 let message = format!(
378 "Non-success status running devcontainer read-configuration for workspace: out: {:?}, err: {:?}",
379 String::from_utf8_lossy(&output.stdout),
380 String::from_utf8_lossy(&output.stderr)
381 );
382 log::error!("{}", &message);
383 Err(DevContainerError::DevContainerNotFound)
384 }
385 }
386 Err(e) => {
387 let message = format!("Error running devcontainer read-configuration: {:?}", e);
388 log::error!("{}", &message);
389 Err(DevContainerError::DevContainerNotFound)
390 }
391 }
392}
393
394async fn devcontainer_template_apply(
395 template: &DevContainerTemplate,
396 template_options: &HashMap<String, String>,
397 features_selected: &HashSet<DevContainerFeature>,
398 path_to_cli: &PathBuf,
399 found_in_path: bool,
400 node_runtime: &NodeRuntime,
401 path: &Arc<Path>,
402 use_podman: bool,
403) -> Result<DevContainerApply, DevContainerError> {
404 let Ok(node_runtime_path) = node_runtime.binary_path().await else {
405 log::error!("Unable to find node runtime path");
406 return Err(DevContainerError::NodeRuntimeNotAvailable);
407 };
408
409 let mut command =
410 devcontainer_cli_command(path_to_cli, found_in_path, &node_runtime_path, use_podman);
411
412 let Ok(serialized_options) = serde_json::to_string(template_options) else {
413 log::error!("Unable to serialize options for {:?}", template_options);
414 return Err(DevContainerError::DevContainerParseFailed);
415 };
416
417 command.arg("templates");
418 command.arg("apply");
419 command.arg("--workspace-folder");
420 command.arg(path.display().to_string());
421 command.arg("--template-id");
422 command.arg(format!(
423 "{}/{}",
424 template
425 .source_repository
426 .as_ref()
427 .unwrap_or(&String::from("")),
428 template.id
429 ));
430 command.arg("--template-args");
431 command.arg(serialized_options);
432 command.arg("--features");
433 command.arg(template_features_to_json(features_selected));
434
435 log::debug!("Running full devcontainer apply command: {:?}", command);
436
437 match command.output().await {
438 Ok(output) => {
439 if output.status.success() {
440 let raw = String::from_utf8_lossy(&output.stdout);
441 parse_json_from_cli(&raw)
442 } else {
443 let message = format!(
444 "Non-success status running devcontainer templates apply for workspace: out: {:?}, err: {:?}",
445 String::from_utf8_lossy(&output.stdout),
446 String::from_utf8_lossy(&output.stderr)
447 );
448
449 log::error!("{}", &message);
450 Err(DevContainerError::DevContainerTemplateApplyFailed(message))
451 }
452 }
453 Err(e) => {
454 let message = format!("Error running devcontainer templates apply: {:?}", e);
455 log::error!("{}", &message);
456 Err(DevContainerError::DevContainerTemplateApplyFailed(message))
457 }
458 }
459}
460// Try to parse directly first (newer versions output pure JSON)
461// If that fails, look for JSON start (older versions have plaintext prefix)
462fn parse_json_from_cli<T: serde::de::DeserializeOwned>(raw: &str) -> Result<T, DevContainerError> {
463 serde_json::from_str::<T>(&raw)
464 .or_else(|e| {
465 log::error!("Error parsing json: {} - will try to find json object in larger plaintext", e);
466 let json_start = raw
467 .find(|c| c == '{')
468 .ok_or_else(|| {
469 log::error!("No JSON found in devcontainer up output");
470 DevContainerError::DevContainerParseFailed
471 })?;
472
473 serde_json::from_str(&raw[json_start..]).map_err(|e| {
474 log::error!(
475 "Unable to parse JSON from devcontainer up output (starting at position {}), error: {:?}",
476 json_start,
477 e
478 );
479 DevContainerError::DevContainerParseFailed
480 })
481 })
482}
483
484fn devcontainer_cli_command(
485 path_to_cli: &PathBuf,
486 found_in_path: bool,
487 node_runtime_path: &PathBuf,
488 use_podman: bool,
489) -> Command {
490 let mut command = if found_in_path {
491 util::command::new_smol_command(path_to_cli.display().to_string())
492 } else {
493 let mut command =
494 util::command::new_smol_command(node_runtime_path.as_os_str().display().to_string());
495 command.arg(path_to_cli.display().to_string());
496 command
497 };
498
499 if use_podman {
500 command.arg("--docker-path");
501 command.arg("podman");
502 }
503 command
504}
505
506fn get_backup_project_name(remote_workspace_folder: &str, container_id: &str) -> String {
507 Path::new(remote_workspace_folder)
508 .file_name()
509 .and_then(|name| name.to_str())
510 .map(|string| string.to_string())
511 .unwrap_or_else(|| container_id.to_string())
512}
513
514fn project_directory(cx: &mut AsyncWindowContext) -> Option<Arc<Path>> {
515 let Some(workspace) = cx.window_handle().downcast::<Workspace>() else {
516 return None;
517 };
518
519 match workspace.update(cx, |workspace, _, cx| {
520 workspace.project().read(cx).active_project_directory(cx)
521 }) {
522 Ok(dir) => dir,
523 Err(e) => {
524 log::error!("Error getting project directory from workspace: {:?}", e);
525 None
526 }
527 }
528}
529
530fn template_features_to_json(features_selected: &HashSet<DevContainerFeature>) -> String {
531 let features_map = features_selected
532 .iter()
533 .map(|feature| {
534 let mut map = HashMap::new();
535 map.insert(
536 "id",
537 format!(
538 "{}/{}:{}",
539 feature
540 .source_repository
541 .as_ref()
542 .unwrap_or(&String::from("")),
543 feature.id,
544 feature.major_version()
545 ),
546 );
547 map
548 })
549 .collect::<Vec<HashMap<&str, String>>>();
550 serde_json::to_string(&features_map).unwrap()
551}
552
553#[cfg(test)]
554mod tests {
555 use crate::devcontainer_api::{DevContainerUp, parse_json_from_cli};
556
557 #[test]
558 fn should_parse_from_devcontainer_json() {
559 let json = r#"{"outcome":"success","containerId":"826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/zed"}"#;
560 let up: DevContainerUp = parse_json_from_cli(json).unwrap();
561 assert_eq!(up._outcome, "success");
562 assert_eq!(
563 up.container_id,
564 "826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a"
565 );
566 assert_eq!(up._remote_user, "vscode");
567 assert_eq!(up.remote_workspace_folder, "/workspaces/zed");
568
569 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.
570 {"outcome":"success","containerId":"826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/zed"}"#;
571 let up: DevContainerUp = parse_json_from_cli(json_in_plaintext).unwrap();
572 assert_eq!(up._outcome, "success");
573 assert_eq!(
574 up.container_id,
575 "826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a"
576 );
577 assert_eq!(up._remote_user, "vscode");
578 assert_eq!(up.remote_workspace_folder, "/workspaces/zed");
579 }
580}