dev_container.rs

  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}