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