1use std::fmt::Display;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use gpui::AsyncWindowContext;
6use node_runtime::NodeRuntime;
7use serde::Deserialize;
8use settings::DevContainerConnection;
9use smol::fs;
10use workspace::Workspace;
11
12use crate::remote_connections::Connection;
13
14#[derive(Debug, Deserialize)]
15#[serde(rename_all = "camelCase")]
16struct DevContainerUp {
17 _outcome: String,
18 container_id: String,
19 _remote_user: String,
20 remote_workspace_folder: String,
21}
22
23#[derive(Debug, Deserialize)]
24#[serde(rename_all = "camelCase")]
25struct DevContainerConfiguration {
26 name: Option<String>,
27}
28
29#[derive(Debug, Deserialize)]
30struct DevContainerConfigurationOutput {
31 configuration: DevContainerConfiguration,
32}
33
34#[cfg(not(target_os = "windows"))]
35fn dev_container_cli() -> String {
36 "devcontainer".to_string()
37}
38
39#[cfg(target_os = "windows")]
40fn dev_container_cli() -> String {
41 "devcontainer.cmd".to_string()
42}
43
44async fn check_for_docker() -> Result<(), DevContainerError> {
45 let mut command = util::command::new_smol_command("docker");
46 command.arg("--version");
47
48 match command.output().await {
49 Ok(_) => Ok(()),
50 Err(e) => {
51 log::error!("Unable to find docker in $PATH: {:?}", e);
52 Err(DevContainerError::DockerNotAvailable)
53 }
54 }
55}
56
57async fn ensure_devcontainer_cli(
58 node_runtime: &NodeRuntime,
59) -> Result<(PathBuf, bool), DevContainerError> {
60 let mut command = util::command::new_smol_command(&dev_container_cli());
61 command.arg("--version");
62
63 if let Err(e) = command.output().await {
64 log::error!(
65 "Unable to find devcontainer CLI in $PATH. Checking for a zed installed version. Error: {:?}",
66 e
67 );
68
69 let Ok(node_runtime_path) = node_runtime.binary_path().await else {
70 return Err(DevContainerError::NodeRuntimeNotAvailable);
71 };
72
73 let datadir_cli_path = paths::devcontainer_dir()
74 .join("node_modules")
75 .join("@devcontainers")
76 .join("cli")
77 .join(format!("{}.js", &dev_container_cli()));
78
79 log::debug!(
80 "devcontainer not found in path, using local location: ${}",
81 datadir_cli_path.display()
82 );
83
84 let mut command =
85 util::command::new_smol_command(node_runtime_path.as_os_str().display().to_string());
86 command.arg(datadir_cli_path.display().to_string());
87 command.arg("--version");
88
89 match command.output().await {
90 Err(e) => log::error!(
91 "Unable to find devcontainer CLI in Data dir. Will try to install. Error: {:?}",
92 e
93 ),
94 Ok(output) => {
95 if output.status.success() {
96 log::info!("Found devcontainer CLI in Data dir");
97 return Ok((datadir_cli_path.clone(), false));
98 } else {
99 log::error!(
100 "Could not run devcontainer CLI from data_dir. Will try once more to install. Output: {:?}",
101 output
102 );
103 }
104 }
105 }
106
107 if let Err(e) = fs::create_dir_all(paths::devcontainer_dir()).await {
108 log::error!("Unable to create devcontainer directory. Error: {:?}", e);
109 return Err(DevContainerError::DevContainerCliNotAvailable);
110 }
111
112 if let Err(e) = node_runtime
113 .npm_install_packages(
114 &paths::devcontainer_dir(),
115 &[("@devcontainers/cli", "latest")],
116 )
117 .await
118 {
119 log::error!(
120 "Unable to install devcontainer CLI to data directory. Error: {:?}",
121 e
122 );
123 return Err(DevContainerError::DevContainerCliNotAvailable);
124 };
125
126 let mut command =
127 util::command::new_smol_command(node_runtime_path.as_os_str().display().to_string());
128 command.arg(datadir_cli_path.display().to_string());
129 command.arg("--version");
130 if let Err(e) = command.output().await {
131 log::error!(
132 "Unable to find devcontainer cli after NPM install. Error: {:?}",
133 e
134 );
135 Err(DevContainerError::DevContainerCliNotAvailable)
136 } else {
137 Ok((datadir_cli_path, false))
138 }
139 } else {
140 log::info!("Found devcontainer cli on $PATH, using it");
141 Ok((PathBuf::from(&dev_container_cli()), true))
142 }
143}
144
145async fn devcontainer_up(
146 path_to_cli: &PathBuf,
147 found_in_path: bool,
148 node_runtime: &NodeRuntime,
149 path: Arc<Path>,
150) -> Result<DevContainerUp, DevContainerError> {
151 let Ok(node_runtime_path) = node_runtime.binary_path().await else {
152 log::error!("Unable to find node runtime path");
153 return Err(DevContainerError::NodeRuntimeNotAvailable);
154 };
155
156 let mut command = if found_in_path {
157 let mut command = util::command::new_smol_command(path_to_cli.display().to_string());
158 command.arg("up");
159 command.arg("--workspace-folder");
160 command.arg(path.display().to_string());
161 command
162 } else {
163 let mut command =
164 util::command::new_smol_command(node_runtime_path.as_os_str().display().to_string());
165 command.arg(path_to_cli.display().to_string());
166 command.arg("up");
167 command.arg("--workspace-folder");
168 command.arg(path.display().to_string());
169 command
170 };
171
172 log::debug!("Running full devcontainer up command: {:?}", command);
173
174 match command.output().await {
175 Ok(output) => {
176 if output.status.success() {
177 let raw = String::from_utf8_lossy(&output.stdout);
178 serde_json::from_str::<DevContainerUp>(&raw).map_err(|e| {
179 log::error!(
180 "Unable to parse response from 'devcontainer up' command, error: {:?}",
181 e
182 );
183 DevContainerError::DevContainerParseFailed
184 })
185 } else {
186 let message = format!(
187 "Non-success status running devcontainer up for workspace: out: {:?}, err: {:?}",
188 String::from_utf8_lossy(&output.stdout),
189 String::from_utf8_lossy(&output.stderr)
190 );
191
192 log::error!("{}", &message);
193 Err(DevContainerError::DevContainerUpFailed(message))
194 }
195 }
196 Err(e) => {
197 let message = format!("Error running devcontainer up: {:?}", e);
198 log::error!("{}", &message);
199 Err(DevContainerError::DevContainerUpFailed(message))
200 }
201 }
202}
203
204async fn devcontainer_read_configuration(
205 path_to_cli: &PathBuf,
206 path: Arc<Path>,
207) -> Result<DevContainerConfigurationOutput, DevContainerError> {
208 let mut command = util::command::new_smol_command(path_to_cli.display().to_string());
209 command.arg("read-configuration");
210 command.arg("--workspace-folder");
211 command.arg(path.display().to_string());
212 match command.output().await {
213 Ok(output) => {
214 if output.status.success() {
215 let raw = String::from_utf8_lossy(&output.stdout);
216 serde_json::from_str::<DevContainerConfigurationOutput>(&raw).map_err(|e| {
217 log::error!(
218 "Unable to parse response from 'devcontainer read-configuration' command, error: {:?}",
219 e
220 );
221 DevContainerError::DevContainerParseFailed
222 })
223 } else {
224 let message = format!(
225 "Non-success status running devcontainer read-configuration for workspace: out: {:?}, err: {:?}",
226 String::from_utf8_lossy(&output.stdout),
227 String::from_utf8_lossy(&output.stderr)
228 );
229 log::error!("{}", &message);
230 Err(DevContainerError::DevContainerUpFailed(message))
231 }
232 }
233 Err(e) => {
234 let message = format!("Error running devcontainer read-configuration: {:?}", e);
235 log::error!("{}", &message);
236 Err(DevContainerError::DevContainerUpFailed(message))
237 }
238 }
239}
240
241// Name the project with two fallbacks
242async fn get_project_name(
243 path_to_cli: &PathBuf,
244 path: Arc<Path>,
245 remote_workspace_folder: String,
246 container_id: String,
247) -> Result<String, DevContainerError> {
248 if let Ok(dev_container_configuration) =
249 devcontainer_read_configuration(path_to_cli, path).await
250 && let Some(name) = dev_container_configuration.configuration.name
251 {
252 // Ideally, name the project after the name defined in devcontainer.json
253 Ok(name)
254 } else {
255 // Otherwise, name the project after the remote workspace folder name
256 Ok(Path::new(&remote_workspace_folder)
257 .file_name()
258 .and_then(|name| name.to_str())
259 .map(|string| string.into())
260 // Finally, name the project after the container ID as a last resort
261 .unwrap_or_else(|| container_id.clone()))
262 }
263}
264
265fn project_directory(cx: &mut AsyncWindowContext) -> Option<Arc<Path>> {
266 let Some(workspace) = cx.window_handle().downcast::<Workspace>() else {
267 return None;
268 };
269
270 match workspace.update(cx, |workspace, _, cx| {
271 workspace.project().read(cx).active_project_directory(cx)
272 }) {
273 Ok(dir) => dir,
274 Err(e) => {
275 log::error!("Error getting project directory from workspace: {:?}", e);
276 None
277 }
278 }
279}
280
281pub(crate) async fn start_dev_container(
282 cx: &mut AsyncWindowContext,
283 node_runtime: NodeRuntime,
284) -> Result<(Connection, String), DevContainerError> {
285 check_for_docker().await?;
286
287 let (path_to_devcontainer_cli, found_in_path) = ensure_devcontainer_cli(&node_runtime).await?;
288
289 let Some(directory) = project_directory(cx) else {
290 return Err(DevContainerError::DevContainerNotFound);
291 };
292
293 match devcontainer_up(
294 &path_to_devcontainer_cli,
295 found_in_path,
296 &node_runtime,
297 directory.clone(),
298 )
299 .await
300 {
301 Ok(DevContainerUp {
302 container_id,
303 remote_workspace_folder,
304 ..
305 }) => {
306 let project_name = get_project_name(
307 &path_to_devcontainer_cli,
308 directory,
309 remote_workspace_folder.clone(),
310 container_id.clone(),
311 )
312 .await?;
313
314 let connection = Connection::DevContainer(DevContainerConnection {
315 name: project_name.into(),
316 container_id: container_id.into(),
317 });
318
319 Ok((connection, remote_workspace_folder))
320 }
321 Err(err) => {
322 let message = format!("Failed with nested error: {}", err);
323 Err(DevContainerError::DevContainerUpFailed(message))
324 }
325 }
326}
327
328#[derive(Debug)]
329pub(crate) enum DevContainerError {
330 DockerNotAvailable,
331 DevContainerCliNotAvailable,
332 DevContainerUpFailed(String),
333 DevContainerNotFound,
334 DevContainerParseFailed,
335 NodeRuntimeNotAvailable,
336}
337
338impl Display for DevContainerError {
339 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
340 write!(
341 f,
342 "{}",
343 match self {
344 DevContainerError::DockerNotAvailable =>
345 "Docker CLI not found on $PATH".to_string(),
346 DevContainerError::DevContainerCliNotAvailable =>
347 "Docker not found on path".to_string(),
348 DevContainerError::DevContainerUpFailed(message) => {
349 format!("DevContainer creation failed with error: {}", message)
350 }
351 DevContainerError::DevContainerNotFound => "TODO what".to_string(),
352 DevContainerError::DevContainerParseFailed =>
353 "Failed to parse file .devcontainer/devcontainer.json".to_string(),
354 DevContainerError::NodeRuntimeNotAvailable =>
355 "Cannot find a valid node runtime".to_string(),
356 }
357 )
358 }
359}
360
361#[cfg(test)]
362mod test {
363
364 use crate::dev_container::DevContainerUp;
365
366 #[test]
367 fn should_parse_from_devcontainer_json() {
368 let json = r#"{"outcome":"success","containerId":"826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/zed"}"#;
369 let up: DevContainerUp = serde_json::from_str(json).unwrap();
370 assert_eq!(up._outcome, "success");
371 assert_eq!(
372 up.container_id,
373 "826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a"
374 );
375 assert_eq!(up._remote_user, "vscode");
376 assert_eq!(up.remote_workspace_folder, "/workspaces/zed");
377 }
378}