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(node_runtime: NodeRuntime) -> Result<PathBuf, DevContainerError> {
57 let mut command = util::command::new_smol_command(&dev_container_cli());
58 command.arg("--version");
59
60 if let Err(e) = command.output().await {
61 log::error!(
62 "Unable to find devcontainer CLI in $PATH. Checking for a zed installed version. Error: {:?}",
63 e
64 );
65
66 let datadir_cli_path = paths::devcontainer_dir()
67 .join("node_modules")
68 .join(".bin")
69 .join(&dev_container_cli());
70
71 let mut command =
72 util::command::new_smol_command(&datadir_cli_path.as_os_str().display().to_string());
73 command.arg("--version");
74
75 if let Err(e) = command.output().await {
76 log::error!(
77 "Unable to find devcontainer CLI in Data dir. Will try to install. Error: {:?}",
78 e
79 );
80 } else {
81 log::info!("Found devcontainer CLI in Data dir");
82 return Ok(datadir_cli_path.clone());
83 }
84
85 if let Err(e) = fs::create_dir_all(paths::devcontainer_dir()).await {
86 log::error!("Unable to create devcontainer directory. Error: {:?}", e);
87 return Err(DevContainerError::DevContainerCliNotAvailable);
88 }
89
90 if let Err(e) = node_runtime
91 .npm_install_packages(
92 &paths::devcontainer_dir(),
93 &[("@devcontainers/cli", "latest")],
94 )
95 .await
96 {
97 log::error!(
98 "Unable to install devcontainer CLI to data directory. Error: {:?}",
99 e
100 );
101 return Err(DevContainerError::DevContainerCliNotAvailable);
102 };
103
104 let mut command = util::command::new_smol_command(&datadir_cli_path.display().to_string());
105 command.arg("--version");
106 if let Err(e) = command.output().await {
107 log::error!(
108 "Unable to find devcontainer cli after NPM install. Error: {:?}",
109 e
110 );
111 Err(DevContainerError::DevContainerCliNotAvailable)
112 } else {
113 Ok(datadir_cli_path)
114 }
115 } else {
116 log::info!("Found devcontainer cli on $PATH, using it");
117 Ok(PathBuf::from(&dev_container_cli()))
118 }
119}
120
121async fn devcontainer_up(
122 path_to_cli: &PathBuf,
123 path: Arc<Path>,
124) -> Result<DevContainerUp, DevContainerError> {
125 let mut command = util::command::new_smol_command(path_to_cli.display().to_string());
126 command.arg("up");
127 command.arg("--workspace-folder");
128 command.arg(path.display().to_string());
129
130 match command.output().await {
131 Ok(output) => {
132 if output.status.success() {
133 let raw = String::from_utf8_lossy(&output.stdout);
134 serde_json::from_str::<DevContainerUp>(&raw).map_err(|e| {
135 log::error!(
136 "Unable to parse response from 'devcontainer up' command, error: {:?}",
137 e
138 );
139 DevContainerError::DevContainerParseFailed
140 })
141 } else {
142 log::error!(
143 "Non-success status running devcontainer up for workspace: out: {:?}, err: {:?}",
144 String::from_utf8_lossy(&output.stdout),
145 String::from_utf8_lossy(&output.stderr)
146 );
147 Err(DevContainerError::DevContainerUpFailed)
148 }
149 }
150 Err(e) => {
151 log::error!("Error running devcontainer up: {:?}", e);
152 Err(DevContainerError::DevContainerUpFailed)
153 }
154 }
155}
156
157async fn devcontainer_read_configuration(
158 path_to_cli: &PathBuf,
159 path: Arc<Path>,
160) -> Result<DevContainerConfigurationOutput, DevContainerError> {
161 let mut command = util::command::new_smol_command(path_to_cli.display().to_string());
162 command.arg("read-configuration");
163 command.arg("--workspace-folder");
164 command.arg(path.display().to_string());
165 match command.output().await {
166 Ok(output) => {
167 if output.status.success() {
168 let raw = String::from_utf8_lossy(&output.stdout);
169 serde_json::from_str::<DevContainerConfigurationOutput>(&raw).map_err(|e| {
170 log::error!(
171 "Unable to parse response from 'devcontainer read-configuration' command, error: {:?}",
172 e
173 );
174 DevContainerError::DevContainerParseFailed
175 })
176 } else {
177 log::error!(
178 "Non-success status running devcontainer read-configuration for workspace: out: {:?}, err: {:?}",
179 String::from_utf8_lossy(&output.stdout),
180 String::from_utf8_lossy(&output.stderr)
181 );
182 Err(DevContainerError::DevContainerUpFailed)
183 }
184 }
185 Err(e) => {
186 log::error!("Error running devcontainer read-configuration: {:?}", e);
187 Err(DevContainerError::DevContainerUpFailed)
188 }
189 }
190}
191
192// Name the project with two fallbacks
193async fn get_project_name(
194 path_to_cli: &PathBuf,
195 path: Arc<Path>,
196 remote_workspace_folder: String,
197 container_id: String,
198) -> Result<String, DevContainerError> {
199 if let Ok(dev_container_configuration) =
200 devcontainer_read_configuration(path_to_cli, path).await
201 && let Some(name) = dev_container_configuration.configuration.name
202 {
203 // Ideally, name the project after the name defined in devcontainer.json
204 Ok(name)
205 } else {
206 // Otherwise, name the project after the remote workspace folder name
207 Ok(Path::new(&remote_workspace_folder)
208 .file_name()
209 .and_then(|name| name.to_str())
210 .map(|string| string.into())
211 // Finally, name the project after the container ID as a last resort
212 .unwrap_or_else(|| container_id.clone()))
213 }
214}
215
216fn project_directory(cx: &mut AsyncWindowContext) -> Option<Arc<Path>> {
217 let Some(workspace) = cx.window_handle().downcast::<Workspace>() else {
218 return None;
219 };
220
221 match workspace.update(cx, |workspace, _, cx| {
222 workspace.project().read(cx).active_project_directory(cx)
223 }) {
224 Ok(dir) => dir,
225 Err(e) => {
226 log::error!("Error getting project directory from workspace: {:?}", e);
227 None
228 }
229 }
230}
231
232pub(crate) async fn start_dev_container(
233 cx: &mut AsyncWindowContext,
234 node_runtime: NodeRuntime,
235) -> Result<(Connection, String), DevContainerError> {
236 check_for_docker().await?;
237
238 let path_to_devcontainer_cli = ensure_devcontainer_cli(node_runtime).await?;
239
240 let Some(directory) = project_directory(cx) else {
241 return Err(DevContainerError::DevContainerNotFound);
242 };
243
244 if let Ok(DevContainerUp {
245 container_id,
246 remote_workspace_folder,
247 ..
248 }) = devcontainer_up(&path_to_devcontainer_cli, directory.clone()).await
249 {
250 let project_name = get_project_name(
251 &path_to_devcontainer_cli,
252 directory,
253 remote_workspace_folder.clone(),
254 container_id.clone(),
255 )
256 .await?;
257
258 let connection = Connection::DevContainer(DevContainerConnection {
259 name: project_name.into(),
260 container_id: container_id.into(),
261 });
262
263 Ok((connection, remote_workspace_folder))
264 } else {
265 Err(DevContainerError::DevContainerUpFailed)
266 }
267}
268
269#[derive(Debug)]
270pub(crate) enum DevContainerError {
271 DockerNotAvailable,
272 DevContainerCliNotAvailable,
273 DevContainerUpFailed,
274 DevContainerNotFound,
275 DevContainerParseFailed,
276}
277
278#[cfg(test)]
279mod test {
280
281 use crate::dev_container::DevContainerUp;
282
283 #[test]
284 fn should_parse_from_devcontainer_json() {
285 let json = r#"{"outcome":"success","containerId":"826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/zed"}"#;
286 let up: DevContainerUp = serde_json::from_str(json).unwrap();
287 assert_eq!(up._outcome, "success");
288 assert_eq!(
289 up.container_id,
290 "826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a"
291 );
292 assert_eq!(up._remote_user, "vscode");
293 assert_eq!(up.remote_workspace_folder, "/workspaces/zed");
294 }
295}