1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use gpui::{
5 Action, AsyncWindowContext, DismissEvent, EventEmitter, FocusHandle, Focusable, RenderOnce,
6};
7use node_runtime::NodeRuntime;
8use serde::Deserialize;
9use settings::DevContainerConnection;
10use smol::fs;
11use ui::{
12 App, Color, Context, Headline, HeadlineSize, Icon, IconName, InteractiveElement, IntoElement,
13 Label, ListItem, ListSeparator, ModalHeader, Navigable, NavigableEntry, ParentElement, Render,
14 Styled, StyledExt, Toggleable, Window, div, rems,
15};
16use workspace::{ModalView, Workspace, with_active_or_new_workspace};
17
18use crate::remote_connections::Connection;
19
20#[derive(Debug, Deserialize)]
21#[serde(rename_all = "camelCase")]
22struct DevContainerUp {
23 _outcome: String,
24 container_id: String,
25 _remote_user: String,
26 remote_workspace_folder: String,
27}
28
29#[derive(Debug, Deserialize)]
30#[serde(rename_all = "camelCase")]
31struct DevContainerConfiguration {
32 name: Option<String>,
33}
34
35#[derive(Debug, Deserialize)]
36struct DevContainerConfigurationOutput {
37 configuration: DevContainerConfiguration,
38}
39
40#[cfg(not(target_os = "windows"))]
41fn dev_container_cli() -> String {
42 "devcontainer".to_string()
43}
44
45#[cfg(target_os = "windows")]
46fn dev_container_cli() -> String {
47 "devcontainer.cmd".to_string()
48}
49
50async fn check_for_docker() -> Result<(), DevContainerError> {
51 let mut command = util::command::new_smol_command("docker");
52 command.arg("--version");
53
54 match command.output().await {
55 Ok(_) => Ok(()),
56 Err(e) => {
57 log::error!("Unable to find docker in $PATH: {:?}", e);
58 Err(DevContainerError::DockerNotAvailable)
59 }
60 }
61}
62
63async fn ensure_devcontainer_cli(node_runtime: NodeRuntime) -> Result<PathBuf, DevContainerError> {
64 let mut command = util::command::new_smol_command(&dev_container_cli());
65 command.arg("--version");
66
67 if let Err(e) = command.output().await {
68 log::error!(
69 "Unable to find devcontainer CLI in $PATH. Checking for a zed installed version. Error: {:?}",
70 e
71 );
72
73 let datadir_cli_path = paths::devcontainer_dir()
74 .join("node_modules")
75 .join(".bin")
76 .join(&dev_container_cli());
77
78 let mut command =
79 util::command::new_smol_command(&datadir_cli_path.as_os_str().display().to_string());
80 command.arg("--version");
81
82 if let Err(e) = command.output().await {
83 log::error!(
84 "Unable to find devcontainer CLI in Data dir. Will try to install. Error: {:?}",
85 e
86 );
87 } else {
88 log::info!("Found devcontainer CLI in Data dir");
89 return Ok(datadir_cli_path.clone());
90 }
91
92 if let Err(e) = fs::create_dir_all(paths::devcontainer_dir()).await {
93 log::error!("Unable to create devcontainer directory. Error: {:?}", e);
94 return Err(DevContainerError::DevContainerCliNotAvailable);
95 }
96
97 if let Err(e) = node_runtime
98 .npm_install_packages(
99 &paths::devcontainer_dir(),
100 &[("@devcontainers/cli", "latest")],
101 )
102 .await
103 {
104 log::error!(
105 "Unable to install devcontainer CLI to data directory. Error: {:?}",
106 e
107 );
108 return Err(DevContainerError::DevContainerCliNotAvailable);
109 };
110
111 let mut command = util::command::new_smol_command(&datadir_cli_path.display().to_string());
112 command.arg("--version");
113 if let Err(e) = command.output().await {
114 log::error!(
115 "Unable to find devcontainer cli after NPM install. Error: {:?}",
116 e
117 );
118 Err(DevContainerError::DevContainerCliNotAvailable)
119 } else {
120 Ok(datadir_cli_path)
121 }
122 } else {
123 log::info!("Found devcontainer cli on $PATH, using it");
124 Ok(PathBuf::from(&dev_container_cli()))
125 }
126}
127
128async fn devcontainer_up(
129 path_to_cli: &PathBuf,
130 path: Arc<Path>,
131) -> Result<DevContainerUp, DevContainerError> {
132 let mut command = util::command::new_smol_command(path_to_cli.display().to_string());
133 command.arg("up");
134 command.arg("--workspace-folder");
135 command.arg(path.display().to_string());
136
137 match command.output().await {
138 Ok(output) => {
139 if output.status.success() {
140 let raw = String::from_utf8_lossy(&output.stdout);
141 serde_json::from_str::<DevContainerUp>(&raw).map_err(|e| {
142 log::error!(
143 "Unable to parse response from 'devcontainer up' command, error: {:?}",
144 e
145 );
146 DevContainerError::DevContainerParseFailed
147 })
148 } else {
149 log::error!(
150 "Non-success status running devcontainer up for workspace: out: {:?}, err: {:?}",
151 String::from_utf8_lossy(&output.stdout),
152 String::from_utf8_lossy(&output.stderr)
153 );
154 Err(DevContainerError::DevContainerUpFailed)
155 }
156 }
157 Err(e) => {
158 log::error!("Error running devcontainer up: {:?}", e);
159 Err(DevContainerError::DevContainerUpFailed)
160 }
161 }
162}
163
164async fn devcontainer_read_configuration(
165 path_to_cli: &PathBuf,
166 path: Arc<Path>,
167) -> Result<DevContainerConfigurationOutput, DevContainerError> {
168 let mut command = util::command::new_smol_command(path_to_cli.display().to_string());
169 command.arg("read-configuration");
170 command.arg("--workspace-folder");
171 command.arg(path.display().to_string());
172 match command.output().await {
173 Ok(output) => {
174 if output.status.success() {
175 let raw = String::from_utf8_lossy(&output.stdout);
176 serde_json::from_str::<DevContainerConfigurationOutput>(&raw).map_err(|e| {
177 log::error!(
178 "Unable to parse response from 'devcontainer read-configuration' command, error: {:?}",
179 e
180 );
181 DevContainerError::DevContainerParseFailed
182 })
183 } else {
184 log::error!(
185 "Non-success status running devcontainer read-configuration for workspace: out: {:?}, err: {:?}",
186 String::from_utf8_lossy(&output.stdout),
187 String::from_utf8_lossy(&output.stderr)
188 );
189 Err(DevContainerError::DevContainerUpFailed)
190 }
191 }
192 Err(e) => {
193 log::error!("Error running devcontainer read-configuration: {:?}", e);
194 Err(DevContainerError::DevContainerUpFailed)
195 }
196 }
197}
198
199// Name the project with two fallbacks
200async fn get_project_name(
201 path_to_cli: &PathBuf,
202 path: Arc<Path>,
203 remote_workspace_folder: String,
204 container_id: String,
205) -> Result<String, DevContainerError> {
206 if let Ok(dev_container_configuration) =
207 devcontainer_read_configuration(path_to_cli, path).await
208 && let Some(name) = dev_container_configuration.configuration.name
209 {
210 // Ideally, name the project after the name defined in devcontainer.json
211 Ok(name)
212 } else {
213 // Otherwise, name the project after the remote workspace folder name
214 Ok(Path::new(&remote_workspace_folder)
215 .file_name()
216 .and_then(|name| name.to_str())
217 .map(|string| string.into())
218 // Finally, name the project after the container ID as a last resort
219 .unwrap_or_else(|| container_id.clone()))
220 }
221}
222
223fn project_directory(cx: &mut AsyncWindowContext) -> Option<Arc<Path>> {
224 let Some(workspace) = cx.window_handle().downcast::<Workspace>() else {
225 return None;
226 };
227
228 match workspace.update(cx, |workspace, _, cx| {
229 workspace.project().read(cx).active_project_directory(cx)
230 }) {
231 Ok(dir) => dir,
232 Err(e) => {
233 log::error!("Error getting project directory from workspace: {:?}", e);
234 None
235 }
236 }
237}
238
239pub(crate) async fn start_dev_container(
240 cx: &mut AsyncWindowContext,
241 node_runtime: NodeRuntime,
242) -> Result<(Connection, String), DevContainerError> {
243 check_for_docker().await?;
244
245 let path_to_devcontainer_cli = ensure_devcontainer_cli(node_runtime).await?;
246
247 let Some(directory) = project_directory(cx) else {
248 return Err(DevContainerError::DevContainerNotFound);
249 };
250
251 if let Ok(DevContainerUp {
252 container_id,
253 remote_workspace_folder,
254 ..
255 }) = devcontainer_up(&path_to_devcontainer_cli, directory.clone()).await
256 {
257 let project_name = get_project_name(
258 &path_to_devcontainer_cli,
259 directory,
260 remote_workspace_folder.clone(),
261 container_id.clone(),
262 )
263 .await?;
264
265 let connection = Connection::DevContainer(DevContainerConnection {
266 name: project_name.into(),
267 container_id: container_id.into(),
268 });
269
270 Ok((connection, remote_workspace_folder))
271 } else {
272 Err(DevContainerError::DevContainerUpFailed)
273 }
274}
275
276#[derive(Debug)]
277pub(crate) enum DevContainerError {
278 DockerNotAvailable,
279 DevContainerCliNotAvailable,
280 DevContainerUpFailed,
281 DevContainerNotFound,
282 DevContainerParseFailed,
283}
284
285#[derive(PartialEq, Clone, Deserialize, Default, Action)]
286#[action(namespace = containers)]
287#[serde(deny_unknown_fields)]
288pub struct InitDevContainer;
289
290pub fn init(cx: &mut App) {
291 cx.on_action(|_: &InitDevContainer, cx| {
292 with_active_or_new_workspace(cx, move |workspace, window, cx| {
293 workspace.toggle_modal(window, cx, |window, cx| DevContainerModal::new(window, cx));
294 });
295 });
296}
297
298struct DevContainerModal {
299 focus_handle: FocusHandle,
300 search_navigable_entry: NavigableEntry,
301 other_navigable_entry: NavigableEntry,
302}
303
304impl DevContainerModal {
305 fn new(window: &mut Window, cx: &mut App) -> Self {
306 let search_navigable_entry = NavigableEntry::focusable(cx);
307 let other_navigable_entry = NavigableEntry::focusable(cx);
308 let focus_handle = cx.focus_handle();
309 DevContainerModal {
310 focus_handle,
311 search_navigable_entry,
312 other_navigable_entry,
313 }
314 }
315
316 fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
317 cx.emit(DismissEvent);
318 }
319}
320
321impl ModalView for DevContainerModal {}
322impl EventEmitter<DismissEvent> for DevContainerModal {}
323impl Focusable for DevContainerModal {
324 fn focus_handle(&self, _cx: &App) -> FocusHandle {
325 self.focus_handle.clone()
326 }
327}
328
329impl Render for DevContainerModal {
330 fn render(
331 &mut self,
332 window: &mut ui::Window,
333 cx: &mut ui::Context<Self>,
334 ) -> impl ui::IntoElement {
335 let mut view =
336 Navigable::new(
337 div()
338 .child(div().track_focus(&self.focus_handle).child(
339 ModalHeader::new().child(
340 Headline::new("Create Dev Container").size(HeadlineSize::XSmall),
341 ),
342 ))
343 .child(ListSeparator)
344 .child(
345 div()
346 .track_focus(&self.search_navigable_entry.focus_handle)
347 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
348 println!("action on search containers");
349 }))
350 .child(
351 ListItem::new("li-search-containers")
352 .inset(true)
353 .spacing(ui::ListItemSpacing::Sparse)
354 .start_slot(Icon::new(IconName::Pencil).color(Color::Muted))
355 .toggle_state(
356 self.search_navigable_entry
357 .focus_handle
358 .contains_focused(window, cx),
359 )
360 .child(Label::new("Search for dev containers in registry")),
361 ),
362 )
363 .child(
364 div()
365 .track_focus(&self.other_navigable_entry.focus_handle)
366 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
367 println!("action on other containers");
368 }))
369 .child(
370 ListItem::new("li-search-containers")
371 .inset(true)
372 .spacing(ui::ListItemSpacing::Sparse)
373 .start_slot(Icon::new(IconName::Pencil).color(Color::Muted))
374 .toggle_state(
375 self.other_navigable_entry
376 .focus_handle
377 .contains_focused(window, cx),
378 )
379 .child(Label::new("Do another thing")),
380 ),
381 )
382 .into_any_element(),
383 );
384 view = view.entry(self.search_navigable_entry.clone());
385 view = view.entry(self.other_navigable_entry.clone());
386
387 // // This is an interesting edge. Can't focus in render, or you'll just override whatever was focused before.
388 // // self.search_navigable_entry.focus_handle.focus(window, cx);
389
390 // view.render(window, cx).into_any_element()
391 div()
392 .elevation_3(cx)
393 .w(rems(34.))
394 // WHY IS THIS NEEDED FOR ACTION DISPATCH OMG
395 .key_context("ContainerModal")
396 .on_action(cx.listener(Self::dismiss))
397 .child(view.render(window, cx).into_any_element())
398 }
399}
400
401#[cfg(test)]
402mod test {
403
404 use crate::dev_container::DevContainerUp;
405
406 #[test]
407 fn should_parse_from_devcontainer_json() {
408 let json = r#"{"outcome":"success","containerId":"826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/zed"}"#;
409 let up: DevContainerUp = serde_json::from_str(json).unwrap();
410 assert_eq!(up._outcome, "success");
411 assert_eq!(
412 up.container_id,
413 "826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a"
414 );
415 assert_eq!(up._remote_user, "vscode");
416 assert_eq!(up.remote_workspace_folder, "/workspaces/zed");
417 }
418}