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 remote_user,
160 ..
161 }) => {
162 let project_name = match devcontainer_read_configuration(
163 &path_to_devcontainer_cli,
164 found_in_path,
165 &node_runtime,
166 &directory,
167 use_podman,
168 )
169 .await
170 {
171 Ok(DevContainerConfigurationOutput {
172 configuration:
173 DevContainerConfiguration {
174 name: Some(project_name),
175 },
176 }) => project_name,
177 _ => get_backup_project_name(&remote_workspace_folder, &container_id),
178 };
179
180 let connection = DevContainerConnection {
181 name: project_name,
182 container_id: container_id,
183 use_podman,
184 remote_user,
185 };
186
187 Ok((connection, remote_workspace_folder))
188 }
189 Err(err) => {
190 let message = format!("Failed with nested error: {}", err);
191 Err(DevContainerError::DevContainerUpFailed(message))
192 }
193 }
194}
195
196#[cfg(not(target_os = "windows"))]
197fn dev_container_cli() -> String {
198 "devcontainer".to_string()
199}
200
201#[cfg(target_os = "windows")]
202fn dev_container_cli() -> String {
203 "devcontainer.cmd".to_string()
204}
205
206async fn check_for_docker(use_podman: bool) -> Result<(), DevContainerError> {
207 let mut command = if use_podman {
208 util::command::new_smol_command("podman")
209 } else {
210 util::command::new_smol_command("docker")
211 };
212 command.arg("--version");
213
214 match command.output().await {
215 Ok(_) => Ok(()),
216 Err(e) => {
217 log::error!("Unable to find docker in $PATH: {:?}", e);
218 Err(DevContainerError::DockerNotAvailable)
219 }
220 }
221}
222
223async fn ensure_devcontainer_cli(
224 node_runtime: &NodeRuntime,
225) -> Result<(PathBuf, bool), DevContainerError> {
226 let mut command = util::command::new_smol_command(&dev_container_cli());
227 command.arg("--version");
228
229 if let Err(e) = command.output().await {
230 log::error!(
231 "Unable to find devcontainer CLI in $PATH. Checking for a zed installed version. Error: {:?}",
232 e
233 );
234
235 let Ok(node_runtime_path) = node_runtime.binary_path().await else {
236 return Err(DevContainerError::NodeRuntimeNotAvailable);
237 };
238
239 let datadir_cli_path = paths::devcontainer_dir()
240 .join("node_modules")
241 .join("@devcontainers")
242 .join("cli")
243 .join(format!("{}.js", &dev_container_cli()));
244
245 log::debug!(
246 "devcontainer not found in path, using local location: ${}",
247 datadir_cli_path.display()
248 );
249
250 let mut command =
251 util::command::new_smol_command(node_runtime_path.as_os_str().display().to_string());
252 command.arg(datadir_cli_path.display().to_string());
253 command.arg("--version");
254
255 match command.output().await {
256 Err(e) => log::error!(
257 "Unable to find devcontainer CLI in Data dir. Will try to install. Error: {:?}",
258 e
259 ),
260 Ok(output) => {
261 if output.status.success() {
262 log::info!("Found devcontainer CLI in Data dir");
263 return Ok((datadir_cli_path.clone(), false));
264 } else {
265 log::error!(
266 "Could not run devcontainer CLI from data_dir. Will try once more to install. Output: {:?}",
267 output
268 );
269 }
270 }
271 }
272
273 if let Err(e) = fs::create_dir_all(paths::devcontainer_dir()).await {
274 log::error!("Unable to create devcontainer directory. Error: {:?}", e);
275 return Err(DevContainerError::DevContainerCliNotAvailable);
276 }
277
278 if let Err(e) = node_runtime
279 .npm_install_packages(
280 &paths::devcontainer_dir(),
281 &[("@devcontainers/cli", "latest")],
282 )
283 .await
284 {
285 log::error!(
286 "Unable to install devcontainer CLI to data directory. Error: {:?}",
287 e
288 );
289 return Err(DevContainerError::DevContainerCliNotAvailable);
290 };
291
292 let mut command =
293 util::command::new_smol_command(node_runtime_path.as_os_str().display().to_string());
294 command.arg(datadir_cli_path.display().to_string());
295 command.arg("--version");
296 if let Err(e) = command.output().await {
297 log::error!(
298 "Unable to find devcontainer cli after NPM install. Error: {:?}",
299 e
300 );
301 Err(DevContainerError::DevContainerCliNotAvailable)
302 } else {
303 Ok((datadir_cli_path, false))
304 }
305 } else {
306 log::info!("Found devcontainer cli on $PATH, using it");
307 Ok((PathBuf::from(&dev_container_cli()), true))
308 }
309}
310
311async fn devcontainer_up(
312 path_to_cli: &PathBuf,
313 found_in_path: bool,
314 node_runtime: &NodeRuntime,
315 path: Arc<Path>,
316 use_podman: bool,
317) -> Result<DevContainerUp, DevContainerError> {
318 let Ok(node_runtime_path) = node_runtime.binary_path().await else {
319 log::error!("Unable to find node runtime path");
320 return Err(DevContainerError::NodeRuntimeNotAvailable);
321 };
322
323 let mut command =
324 devcontainer_cli_command(path_to_cli, found_in_path, &node_runtime_path, use_podman);
325 command.arg("up");
326 command.arg("--workspace-folder");
327 command.arg(path.display().to_string());
328
329 log::info!("Running full devcontainer up command: {:?}", command);
330
331 match command.output().await {
332 Ok(output) => {
333 if output.status.success() {
334 let raw = String::from_utf8_lossy(&output.stdout);
335 parse_json_from_cli(&raw)
336 } else {
337 let message = format!(
338 "Non-success status running devcontainer up for workspace: out: {:?}, err: {:?}",
339 String::from_utf8_lossy(&output.stdout),
340 String::from_utf8_lossy(&output.stderr)
341 );
342
343 log::error!("{}", &message);
344 Err(DevContainerError::DevContainerUpFailed(message))
345 }
346 }
347 Err(e) => {
348 let message = format!("Error running devcontainer up: {:?}", e);
349 log::error!("{}", &message);
350 Err(DevContainerError::DevContainerUpFailed(message))
351 }
352 }
353}
354
355async fn devcontainer_read_configuration(
356 path_to_cli: &PathBuf,
357 found_in_path: bool,
358 node_runtime: &NodeRuntime,
359 path: &Arc<Path>,
360 use_podman: bool,
361) -> Result<DevContainerConfigurationOutput, DevContainerError> {
362 let Ok(node_runtime_path) = node_runtime.binary_path().await else {
363 log::error!("Unable to find node runtime path");
364 return Err(DevContainerError::NodeRuntimeNotAvailable);
365 };
366
367 let mut command =
368 devcontainer_cli_command(path_to_cli, found_in_path, &node_runtime_path, use_podman);
369 command.arg("read-configuration");
370 command.arg("--workspace-folder");
371 command.arg(path.display().to_string());
372
373 match command.output().await {
374 Ok(output) => {
375 if output.status.success() {
376 let raw = String::from_utf8_lossy(&output.stdout);
377 parse_json_from_cli(&raw)
378 } else {
379 let message = format!(
380 "Non-success status running devcontainer read-configuration for workspace: out: {:?}, err: {:?}",
381 String::from_utf8_lossy(&output.stdout),
382 String::from_utf8_lossy(&output.stderr)
383 );
384 log::error!("{}", &message);
385 Err(DevContainerError::DevContainerNotFound)
386 }
387 }
388 Err(e) => {
389 let message = format!("Error running devcontainer read-configuration: {:?}", e);
390 log::error!("{}", &message);
391 Err(DevContainerError::DevContainerNotFound)
392 }
393 }
394}
395
396async fn devcontainer_template_apply(
397 template: &DevContainerTemplate,
398 template_options: &HashMap<String, String>,
399 features_selected: &HashSet<DevContainerFeature>,
400 path_to_cli: &PathBuf,
401 found_in_path: bool,
402 node_runtime: &NodeRuntime,
403 path: &Arc<Path>,
404 use_podman: bool,
405) -> Result<DevContainerApply, DevContainerError> {
406 let Ok(node_runtime_path) = node_runtime.binary_path().await else {
407 log::error!("Unable to find node runtime path");
408 return Err(DevContainerError::NodeRuntimeNotAvailable);
409 };
410
411 let mut command =
412 devcontainer_cli_command(path_to_cli, found_in_path, &node_runtime_path, use_podman);
413
414 let Ok(serialized_options) = serde_json::to_string(template_options) else {
415 log::error!("Unable to serialize options for {:?}", template_options);
416 return Err(DevContainerError::DevContainerParseFailed);
417 };
418
419 command.arg("templates");
420 command.arg("apply");
421 command.arg("--workspace-folder");
422 command.arg(path.display().to_string());
423 command.arg("--template-id");
424 command.arg(format!(
425 "{}/{}",
426 template
427 .source_repository
428 .as_ref()
429 .unwrap_or(&String::from("")),
430 template.id
431 ));
432 command.arg("--template-args");
433 command.arg(serialized_options);
434 command.arg("--features");
435 command.arg(template_features_to_json(features_selected));
436
437 log::debug!("Running full devcontainer apply command: {:?}", command);
438
439 match command.output().await {
440 Ok(output) => {
441 if output.status.success() {
442 let raw = String::from_utf8_lossy(&output.stdout);
443 parse_json_from_cli(&raw)
444 } else {
445 let message = format!(
446 "Non-success status running devcontainer templates apply for workspace: out: {:?}, err: {:?}",
447 String::from_utf8_lossy(&output.stdout),
448 String::from_utf8_lossy(&output.stderr)
449 );
450
451 log::error!("{}", &message);
452 Err(DevContainerError::DevContainerTemplateApplyFailed(message))
453 }
454 }
455 Err(e) => {
456 let message = format!("Error running devcontainer templates apply: {:?}", e);
457 log::error!("{}", &message);
458 Err(DevContainerError::DevContainerTemplateApplyFailed(message))
459 }
460 }
461}
462// Try to parse directly first (newer versions output pure JSON)
463// If that fails, look for JSON start (older versions have plaintext prefix)
464fn parse_json_from_cli<T: serde::de::DeserializeOwned>(raw: &str) -> Result<T, DevContainerError> {
465 serde_json::from_str::<T>(&raw)
466 .or_else(|e| {
467 log::error!("Error parsing json: {} - will try to find json object in larger plaintext", e);
468 let json_start = raw
469 .find(|c| c == '{')
470 .ok_or_else(|| {
471 log::error!("No JSON found in devcontainer up output");
472 DevContainerError::DevContainerParseFailed
473 })?;
474
475 serde_json::from_str(&raw[json_start..]).map_err(|e| {
476 log::error!(
477 "Unable to parse JSON from devcontainer up output (starting at position {}), error: {:?}",
478 json_start,
479 e
480 );
481 DevContainerError::DevContainerParseFailed
482 })
483 })
484}
485
486fn devcontainer_cli_command(
487 path_to_cli: &PathBuf,
488 found_in_path: bool,
489 node_runtime_path: &PathBuf,
490 use_podman: bool,
491) -> Command {
492 let mut command = if found_in_path {
493 util::command::new_smol_command(path_to_cli.display().to_string())
494 } else {
495 let mut command =
496 util::command::new_smol_command(node_runtime_path.as_os_str().display().to_string());
497 command.arg(path_to_cli.display().to_string());
498 command
499 };
500
501 if use_podman {
502 command.arg("--docker-path");
503 command.arg("podman");
504 }
505 command
506}
507
508fn get_backup_project_name(remote_workspace_folder: &str, container_id: &str) -> String {
509 Path::new(remote_workspace_folder)
510 .file_name()
511 .and_then(|name| name.to_str())
512 .map(|string| string.to_string())
513 .unwrap_or_else(|| container_id.to_string())
514}
515
516fn project_directory(cx: &mut AsyncWindowContext) -> Option<Arc<Path>> {
517 let Some(workspace) = cx.window_handle().downcast::<Workspace>() else {
518 return None;
519 };
520
521 match workspace.update(cx, |workspace, _, cx| {
522 workspace.project().read(cx).active_project_directory(cx)
523 }) {
524 Ok(dir) => dir,
525 Err(e) => {
526 log::error!("Error getting project directory from workspace: {:?}", e);
527 None
528 }
529 }
530}
531
532fn template_features_to_json(features_selected: &HashSet<DevContainerFeature>) -> String {
533 let features_map = features_selected
534 .iter()
535 .map(|feature| {
536 let mut map = HashMap::new();
537 map.insert(
538 "id",
539 format!(
540 "{}/{}:{}",
541 feature
542 .source_repository
543 .as_ref()
544 .unwrap_or(&String::from("")),
545 feature.id,
546 feature.major_version()
547 ),
548 );
549 map
550 })
551 .collect::<Vec<HashMap<&str, String>>>();
552 serde_json::to_string(&features_map).unwrap()
553}
554
555#[cfg(test)]
556mod tests {
557 use crate::devcontainer_api::{DevContainerUp, parse_json_from_cli};
558
559 #[test]
560 fn should_parse_from_devcontainer_json() {
561 let json = r#"{"outcome":"success","containerId":"826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/zed"}"#;
562 let up: DevContainerUp = parse_json_from_cli(json).unwrap();
563 assert_eq!(up._outcome, "success");
564 assert_eq!(
565 up.container_id,
566 "826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a"
567 );
568 assert_eq!(up.remote_user, "vscode");
569 assert_eq!(up.remote_workspace_folder, "/workspaces/zed");
570
571 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.
572 {"outcome":"success","containerId":"826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/zed"}"#;
573 let up: DevContainerUp = parse_json_from_cli(json_in_plaintext).unwrap();
574 assert_eq!(up._outcome, "success");
575 assert_eq!(
576 up.container_id,
577 "826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a"
578 );
579 assert_eq!(up.remote_user, "vscode");
580 assert_eq!(up.remote_workspace_folder, "/workspaces/zed");
581 }
582}