diff --git a/Cargo.lock b/Cargo.lock index 6c75c448a50c711092a7fde296718bc6e3343379..a8f602640838d3634863fc60a2399e8a9a9f5288 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13157,6 +13157,7 @@ dependencies = [ "askpass", "auto_update", "dap", + "db", "editor", "extension_host", "file_finder", @@ -13168,6 +13169,7 @@ dependencies = [ "log", "markdown", "menu", + "node_runtime", "ordered-float 2.10.1", "paths", "picker", @@ -13186,6 +13188,7 @@ dependencies = [ "util", "windows-registry 0.6.1", "workspace", + "worktree", "zed_actions", ] diff --git a/assets/icons/box.svg b/assets/icons/box.svg new file mode 100644 index 0000000000000000000000000000000000000000..7e1276c629fb8bdc5a7ed48d9e2de6369d4c2bb0 --- /dev/null +++ b/assets/icons/box.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index cc84129250cfdbe968aa3d86f1d00d0789d01480..bf4c74f984ff4aa8f06d6408957eddabcf5f94ed 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -49,6 +49,7 @@ pub enum IconName { BoltOutlined, Book, BookCopy, + Box, CaseSensitive, Chat, Check, diff --git a/crates/languages/src/eslint.rs b/crates/languages/src/eslint.rs index 4f18149265ceac23aadd93b02e7b7309291849fa..fd4133d7ebcafc2553e25c876eb9fb1c6257ebc1 100644 --- a/crates/languages/src/eslint.rs +++ b/crates/languages/src/eslint.rs @@ -126,11 +126,11 @@ impl LspInstaller for EsLintLspAdapter { } self.node - .run_npm_subcommand(&repo_root, "install", &[]) + .run_npm_subcommand(Some(&repo_root), "install", &[]) .await?; self.node - .run_npm_subcommand(&repo_root, "run-script", &["compile"]) + .run_npm_subcommand(Some(&repo_root), "run-script", &["compile"]) .await?; } diff --git a/crates/node_runtime/src/node_runtime.rs b/crates/node_runtime/src/node_runtime.rs index 1faf22dc9844f648fec53654ef3bde500cec32e2..1eb6714500446dbfd2967ed4aa2f514a5f427aba 100644 --- a/crates/node_runtime/src/node_runtime.rs +++ b/crates/node_runtime/src/node_runtime.rs @@ -206,14 +206,14 @@ impl NodeRuntime { pub async fn run_npm_subcommand( &self, - directory: &Path, + directory: Option<&Path>, subcommand: &str, args: &[&str], ) -> Result { let http = self.0.lock().await.http.clone(); self.instance() .await - .run_npm_subcommand(Some(directory), http.proxy(), subcommand, args) + .run_npm_subcommand(directory, http.proxy(), subcommand, args) .await } @@ -283,7 +283,7 @@ impl NodeRuntime { ]); // This is also wrong because the directory is wrong. - self.run_npm_subcommand(directory, "install", &arguments) + self.run_npm_subcommand(Some(directory), "install", &arguments) .await?; Ok(()) } @@ -559,7 +559,10 @@ impl NodeRuntimeTrait for ManagedNodeRuntime { command.env("PATH", env_path); command.env(NODE_CA_CERTS_ENV_VAR, node_ca_certs); command.arg(npm_file).arg(subcommand); - command.args(["--cache".into(), self.installation_path.join("cache")]); + command.arg(format!( + "--cache={}", + self.installation_path.join("cache").display() + )); command.args([ "--userconfig".into(), self.installation_path.join("blank_user_npmrc"), @@ -703,7 +706,10 @@ impl NodeRuntimeTrait for SystemNodeRuntime { .env("PATH", path) .env(NODE_CA_CERTS_ENV_VAR, node_ca_certs) .arg(subcommand) - .args(["--cache".into(), self.scratch_dir.join("cache")]) + .arg(format!( + "--cache={}", + self.scratch_dir.join("cache").display() + )) .args(args); configure_npm_command(&mut command, directory, proxy); let output = command.output().await?; diff --git a/crates/paths/src/paths.rs b/crates/paths/src/paths.rs index 7b5188b0f2b0db1c8b20876e6284209ce91fee6e..a6aa8354b4661fbdf6a3360704d0fb16e5b80614 100644 --- a/crates/paths/src/paths.rs +++ b/crates/paths/src/paths.rs @@ -408,6 +408,12 @@ pub fn remote_servers_dir() -> &'static PathBuf { REMOTE_SERVERS_DIR.get_or_init(|| data_dir().join("remote_servers")) } +/// Returns the path to the directory where the devcontainer CLI is installed. +pub fn devcontainer_dir() -> &'static PathBuf { + static DEVCONTAINER_DIR: OnceLock = OnceLock::new(); + DEVCONTAINER_DIR.get_or_init(|| data_dir().join("devcontainer")) +} + /// Returns the relative path to a `.zed` folder within a project. pub fn local_settings_folder_name() -> &'static str { ".zed" diff --git a/crates/recent_projects/Cargo.toml b/crates/recent_projects/Cargo.toml index abaeafa335fd48991da46268ccd59450e908528c..feaf511b81c73bbf50aae6387b3114b1d96f04c4 100644 --- a/crates/recent_projects/Cargo.toml +++ b/crates/recent_projects/Cargo.toml @@ -16,6 +16,7 @@ doctest = false anyhow.workspace = true askpass.workspace = true auto_update.workspace = true +db.workspace = true editor.workspace = true extension_host.workspace = true file_finder.workspace = true @@ -26,6 +27,7 @@ language.workspace = true log.workspace = true markdown.workspace = true menu.workspace = true +node_runtime.workspace = true ordered-float.workspace = true paths.workspace = true picker.workspace = true @@ -34,6 +36,7 @@ release_channel.workspace = true remote.workspace = true semver.workspace = true serde.workspace = true +serde_json.workspace = true settings.workspace = true smol.workspace = true task.workspace = true @@ -42,6 +45,7 @@ theme.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true +worktree.workspace = true zed_actions.workspace = true indoc.workspace = true diff --git a/crates/recent_projects/src/dev_container.rs b/crates/recent_projects/src/dev_container.rs new file mode 100644 index 0000000000000000000000000000000000000000..0e6b8b381df32d688e062948460707a5f8cfb552 --- /dev/null +++ b/crates/recent_projects/src/dev_container.rs @@ -0,0 +1,295 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use gpui::AsyncWindowContext; +use node_runtime::NodeRuntime; +use serde::Deserialize; +use settings::DevContainerConnection; +use smol::fs; +use workspace::Workspace; + +use crate::remote_connections::Connection; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DevContainerUp { + _outcome: String, + container_id: String, + _remote_user: String, + remote_workspace_folder: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DevContainerConfiguration { + name: Option, +} + +#[derive(Debug, Deserialize)] +struct DevContainerConfigurationOutput { + configuration: DevContainerConfiguration, +} + +#[cfg(not(target_os = "windows"))] +fn dev_container_cli() -> String { + "devcontainer".to_string() +} + +#[cfg(target_os = "windows")] +fn dev_container_cli() -> String { + "devcontainer.cmd".to_string() +} + +async fn check_for_docker() -> Result<(), DevContainerError> { + let mut command = util::command::new_smol_command("docker"); + command.arg("--version"); + + match command.output().await { + Ok(_) => Ok(()), + Err(e) => { + log::error!("Unable to find docker in $PATH: {:?}", e); + Err(DevContainerError::DockerNotAvailable) + } + } +} + +async fn ensure_devcontainer_cli(node_runtime: NodeRuntime) -> Result { + let mut command = util::command::new_smol_command(&dev_container_cli()); + command.arg("--version"); + + if let Err(e) = command.output().await { + log::error!( + "Unable to find devcontainer CLI in $PATH. Checking for a zed installed version. Error: {:?}", + e + ); + + let datadir_cli_path = paths::devcontainer_dir() + .join("node_modules") + .join(".bin") + .join(&dev_container_cli()); + + let mut command = + util::command::new_smol_command(&datadir_cli_path.as_os_str().display().to_string()); + command.arg("--version"); + + if let Err(e) = command.output().await { + log::error!( + "Unable to find devcontainer CLI in Data dir. Will try to install. Error: {:?}", + e + ); + } else { + log::info!("Found devcontainer CLI in Data dir"); + return Ok(datadir_cli_path.clone()); + } + + if let Err(e) = fs::create_dir_all(paths::devcontainer_dir()).await { + log::error!("Unable to create devcontainer directory. Error: {:?}", e); + return Err(DevContainerError::DevContainerCliNotAvailable); + } + + if let Err(e) = node_runtime + .npm_install_packages( + &paths::devcontainer_dir(), + &[("@devcontainers/cli", "latest")], + ) + .await + { + log::error!( + "Unable to install devcontainer CLI to data directory. Error: {:?}", + e + ); + return Err(DevContainerError::DevContainerCliNotAvailable); + }; + + let mut command = util::command::new_smol_command(&datadir_cli_path.display().to_string()); + command.arg("--version"); + if let Err(e) = command.output().await { + log::error!( + "Unable to find devcontainer cli after NPM install. Error: {:?}", + e + ); + Err(DevContainerError::DevContainerCliNotAvailable) + } else { + Ok(datadir_cli_path) + } + } else { + log::info!("Found devcontainer cli on $PATH, using it"); + Ok(PathBuf::from(&dev_container_cli())) + } +} + +async fn devcontainer_up( + path_to_cli: &PathBuf, + path: Arc, +) -> Result { + let mut command = util::command::new_smol_command(path_to_cli.display().to_string()); + command.arg("up"); + command.arg("--workspace-folder"); + command.arg(path.display().to_string()); + + match command.output().await { + Ok(output) => { + if output.status.success() { + let raw = String::from_utf8_lossy(&output.stdout); + serde_json::from_str::(&raw).map_err(|e| { + log::error!( + "Unable to parse response from 'devcontainer up' command, error: {:?}", + e + ); + DevContainerError::DevContainerParseFailed + }) + } else { + log::error!( + "Non-success status running devcontainer up for workspace: out: {:?}, err: {:?}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Err(DevContainerError::DevContainerUpFailed) + } + } + Err(e) => { + log::error!("Error running devcontainer up: {:?}", e); + Err(DevContainerError::DevContainerUpFailed) + } + } +} + +async fn devcontainer_read_configuration( + path_to_cli: &PathBuf, + path: Arc, +) -> Result { + let mut command = util::command::new_smol_command(path_to_cli.display().to_string()); + command.arg("read-configuration"); + command.arg("--workspace-folder"); + command.arg(path.display().to_string()); + match command.output().await { + Ok(output) => { + if output.status.success() { + let raw = String::from_utf8_lossy(&output.stdout); + serde_json::from_str::(&raw).map_err(|e| { + log::error!( + "Unable to parse response from 'devcontainer read-configuration' command, error: {:?}", + e + ); + DevContainerError::DevContainerParseFailed + }) + } else { + log::error!( + "Non-success status running devcontainer read-configuration for workspace: out: {:?}, err: {:?}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Err(DevContainerError::DevContainerUpFailed) + } + } + Err(e) => { + log::error!("Error running devcontainer read-configuration: {:?}", e); + Err(DevContainerError::DevContainerUpFailed) + } + } +} + +// Name the project with two fallbacks +async fn get_project_name( + path_to_cli: &PathBuf, + path: Arc, + remote_workspace_folder: String, + container_id: String, +) -> Result { + if let Ok(dev_container_configuration) = + devcontainer_read_configuration(path_to_cli, path).await + && let Some(name) = dev_container_configuration.configuration.name + { + // Ideally, name the project after the name defined in devcontainer.json + Ok(name) + } else { + // Otherwise, name the project after the remote workspace folder name + Ok(Path::new(&remote_workspace_folder) + .file_name() + .and_then(|name| name.to_str()) + .map(|string| string.into()) + // Finally, name the project after the container ID as a last resort + .unwrap_or_else(|| container_id.clone())) + } +} + +fn project_directory(cx: &mut AsyncWindowContext) -> Option> { + let Some(workspace) = cx.window_handle().downcast::() else { + return None; + }; + + match workspace.update(cx, |workspace, _, cx| { + workspace.project().read(cx).active_project_directory(cx) + }) { + Ok(dir) => dir, + Err(e) => { + log::error!("Error getting project directory from workspace: {:?}", e); + None + } + } +} + +pub(crate) async fn start_dev_container( + cx: &mut AsyncWindowContext, + node_runtime: NodeRuntime, +) -> Result<(Connection, String), DevContainerError> { + check_for_docker().await?; + + let path_to_devcontainer_cli = ensure_devcontainer_cli(node_runtime).await?; + + let Some(directory) = project_directory(cx) else { + return Err(DevContainerError::DevContainerNotFound); + }; + + if let Ok(DevContainerUp { + container_id, + remote_workspace_folder, + .. + }) = devcontainer_up(&path_to_devcontainer_cli, directory.clone()).await + { + let project_name = get_project_name( + &path_to_devcontainer_cli, + directory, + remote_workspace_folder.clone(), + container_id.clone(), + ) + .await?; + + let connection = Connection::DevContainer(DevContainerConnection { + name: project_name.into(), + container_id: container_id.into(), + }); + + Ok((connection, remote_workspace_folder)) + } else { + Err(DevContainerError::DevContainerUpFailed) + } +} + +#[derive(Debug)] +pub(crate) enum DevContainerError { + DockerNotAvailable, + DevContainerCliNotAvailable, + DevContainerUpFailed, + DevContainerNotFound, + DevContainerParseFailed, +} + +#[cfg(test)] +mod test { + + use crate::dev_container::DevContainerUp; + + #[test] + fn should_parse_from_devcontainer_json() { + let json = r#"{"outcome":"success","containerId":"826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/zed"}"#; + let up: DevContainerUp = serde_json::from_str(json).unwrap(); + assert_eq!(up._outcome, "success"); + assert_eq!( + up.container_id, + "826abcac45afd412abff083ab30793daff2f3c8ce2c831df728baf39933cb37a" + ); + assert_eq!(up._remote_user, "vscode"); + assert_eq!(up.remote_workspace_folder, "/workspaces/zed"); + } +} diff --git a/crates/recent_projects/src/dev_container_suggest.rs b/crates/recent_projects/src/dev_container_suggest.rs new file mode 100644 index 0000000000000000000000000000000000000000..1e50080ea15fad714d17e1648b72455b3d401a7a --- /dev/null +++ b/crates/recent_projects/src/dev_container_suggest.rs @@ -0,0 +1,106 @@ +use db::kvp::KEY_VALUE_STORE; +use gpui::{SharedString, Window}; +use project::{Project, WorktreeId}; +use std::sync::LazyLock; +use ui::prelude::*; +use util::rel_path::RelPath; +use workspace::Workspace; +use workspace::notifications::NotificationId; +use workspace::notifications::simple_message_notification::MessageNotification; +use worktree::UpdatedEntriesSet; + +const DEV_CONTAINER_SUGGEST_KEY: &str = "dev_container_suggest_dismissed"; + +fn devcontainer_path() -> &'static RelPath { + static PATH: LazyLock<&'static RelPath> = + LazyLock::new(|| RelPath::unix(".devcontainer").expect("valid path")); + *PATH +} + +fn project_devcontainer_key(project_path: &str) -> String { + format!("{}_{}", DEV_CONTAINER_SUGGEST_KEY, project_path) +} + +pub fn suggest_on_worktree_updated( + worktree_id: WorktreeId, + updated_entries: &UpdatedEntriesSet, + project: &gpui::Entity, + window: &mut Window, + cx: &mut Context, +) { + let devcontainer_updated = updated_entries + .iter() + .any(|(path, _, _)| path.as_ref() == devcontainer_path()); + + if !devcontainer_updated { + return; + } + + let Some(worktree) = project.read(cx).worktree_for_id(worktree_id, cx) else { + return; + }; + + let worktree = worktree.read(cx); + + if !worktree.is_local() { + return; + } + + let has_devcontainer = worktree + .entry_for_path(devcontainer_path()) + .is_some_and(|entry| entry.is_dir()); + + if !has_devcontainer { + return; + } + + let abs_path = worktree.abs_path(); + let project_path = abs_path.to_string_lossy().to_string(); + let key_for_dismiss = project_devcontainer_key(&project_path); + + let already_dismissed = KEY_VALUE_STORE + .read_kvp(&key_for_dismiss) + .ok() + .flatten() + .is_some(); + + if already_dismissed { + return; + } + + cx.on_next_frame(window, move |workspace, _window, cx| { + struct DevContainerSuggestionNotification; + + let notification_id = NotificationId::composite::( + SharedString::from(project_path.clone()), + ); + + workspace.show_notification(notification_id, cx, |cx| { + cx.new(move |cx| { + MessageNotification::new( + "This project contains a Dev Container configuration file. Would you like to re-open it in a container?", + cx, + ) + .primary_message("Yes, Open in Container") + .primary_icon(IconName::Check) + .primary_icon_color(Color::Success) + .primary_on_click({ + move |window, cx| { + window.dispatch_action(Box::new(zed_actions::OpenDevContainer), cx); + } + }) + .secondary_message("Don't Show Again") + .secondary_icon(IconName::Close) + .secondary_icon_color(Color::Error) + .secondary_on_click({ + move |_window, cx| { + let key = key_for_dismiss.clone(); + db::write_and_log(cx, move || { + KEY_VALUE_STORE.write_kvp(key, "dismissed".to_string()) + }); + } + }) + }) + }); + }); +} diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 7647dc1ed46cb9d87c7f889188f834dcbd3a456a..435933a880123c00d3f3fbaaea2c54f6554f0d3b 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -1,8 +1,12 @@ +mod dev_container; +mod dev_container_suggest; pub mod disconnected_overlay; mod remote_connections; mod remote_servers; mod ssh_config; +use std::path::PathBuf; + #[cfg(target_os = "windows")] mod wsl_picker; @@ -31,7 +35,7 @@ use workspace::{ WORKSPACE_DB, Workspace, WorkspaceId, notifications::DetachAndPromptErr, with_active_or_new_workspace, }; -use zed_actions::{OpenRecent, OpenRemote}; +use zed_actions::{OpenDevContainer, OpenRecent, OpenRemote}; pub fn init(cx: &mut App) { #[cfg(target_os = "windows")] @@ -161,6 +165,95 @@ pub fn init(cx: &mut App) { }); cx.observe_new(DisconnectedOverlay::register).detach(); + + cx.on_action(|_: &OpenDevContainer, cx| { + with_active_or_new_workspace(cx, move |workspace, window, cx| { + let app_state = workspace.app_state().clone(); + let replace_window = window.window_handle().downcast::(); + + cx.spawn_in(window, async move |_, mut cx| { + let (connection, starting_dir) = match dev_container::start_dev_container( + &mut cx, + app_state.node_runtime.clone(), + ) + .await + { + Ok((c, s)) => (c, s), + Err(e) => { + log::error!("Failed to start Dev Container: {:?}", e); + cx.prompt( + gpui::PromptLevel::Critical, + "Failed to start Dev Container", + Some(&format!("{:?}", e)), + &["Ok"], + ) + .await + .ok(); + return; + } + }; + + let result = open_remote_project( + connection.into(), + vec![starting_dir].into_iter().map(PathBuf::from).collect(), + app_state, + OpenOptions { + replace_window, + ..OpenOptions::default() + }, + &mut cx, + ) + .await; + + if let Err(e) = result { + log::error!("Failed to connect: {e:#}"); + cx.prompt( + gpui::PromptLevel::Critical, + "Failed to connect", + Some(&e.to_string()), + &["Ok"], + ) + .await + .ok(); + } + }) + .detach(); + + let fs = workspace.project().read(cx).fs().clone(); + let handle = cx.entity().downgrade(); + workspace.toggle_modal(window, cx, |window, cx| { + RemoteServerProjects::new_dev_container(fs, window, handle, cx) + }); + }); + }); + + // Subscribe to worktree additions to suggest opening the project in a dev container + cx.observe_new( + |workspace: &mut Workspace, window: Option<&mut Window>, cx: &mut Context| { + let Some(window) = window else { + return; + }; + cx.subscribe_in( + workspace.project(), + window, + move |_, project, event, window, cx| { + if let project::Event::WorktreeUpdatedEntries(worktree_id, updated_entries) = + event + { + dev_container_suggest::suggest_on_worktree_updated( + *worktree_id, + updated_entries, + project, + window, + cx, + ); + } + }, + ) + .detach(); + }, + ) + .detach(); } #[cfg(target_os = "windows")] @@ -609,6 +702,7 @@ impl PickerDelegate for RecentProjectsDelegate { Icon::new(match options { RemoteConnectionOptions::Ssh { .. } => IconName::Server, RemoteConnectionOptions::Wsl { .. } => IconName::Linux, + RemoteConnectionOptions::Docker(_) => IconName::Box, }) .color(Color::Muted) .into_any_element() diff --git a/crates/recent_projects/src/remote_connections.rs b/crates/recent_projects/src/remote_connections.rs index 562fcccb204212fb43e0b9457b1c08bdb15c3772..c0a655d19e513c838275d3e4f3beadaabcc8fef6 100644 --- a/crates/recent_projects/src/remote_connections.rs +++ b/crates/recent_projects/src/remote_connections.rs @@ -18,16 +18,16 @@ use language::{CursorShape, Point}; use markdown::{Markdown, MarkdownElement, MarkdownStyle}; use release_channel::ReleaseChannel; use remote::{ - ConnectionIdentifier, RemoteClient, RemoteConnection, RemoteConnectionOptions, RemotePlatform, - SshConnectionOptions, + ConnectionIdentifier, DockerConnectionOptions, RemoteClient, RemoteConnection, + RemoteConnectionOptions, RemotePlatform, SshConnectionOptions, }; use semver::Version; pub use settings::SshConnection; -use settings::{ExtendingVec, RegisterSetting, Settings, WslConnection}; +use settings::{DevContainerConnection, ExtendingVec, RegisterSetting, Settings, WslConnection}; use theme::ThemeSettings; use ui::{ - ActiveTheme, Color, CommonAnimationExt, Context, Icon, IconName, IconSize, InteractiveElement, - IntoElement, Label, LabelCommon, Styled, Window, prelude::*, + ActiveTheme, Color, CommonAnimationExt, Context, InteractiveElement, IntoElement, KeyBinding, + LabelCommon, ListItem, Styled, Window, prelude::*, }; use util::paths::PathWithPosition; use workspace::{AppState, ModalView, Workspace}; @@ -85,6 +85,7 @@ impl SshSettings { pub enum Connection { Ssh(SshConnection), Wsl(WslConnection), + DevContainer(DevContainerConnection), } impl From for RemoteConnectionOptions { @@ -92,6 +93,13 @@ impl From for RemoteConnectionOptions { match val { Connection::Ssh(conn) => RemoteConnectionOptions::Ssh(conn.into()), Connection::Wsl(conn) => RemoteConnectionOptions::Wsl(conn.into()), + Connection::DevContainer(conn) => { + RemoteConnectionOptions::Docker(DockerConnectionOptions { + name: conn.name.to_string(), + container_id: conn.container_id.to_string(), + upload_binary_over_docker_exec: false, + }) + } } } } @@ -123,6 +131,7 @@ pub struct RemoteConnectionPrompt { connection_string: SharedString, nickname: Option, is_wsl: bool, + is_devcontainer: bool, status_message: Option, prompt: Option<(Entity, oneshot::Sender)>, cancellation: Option>, @@ -148,6 +157,7 @@ impl RemoteConnectionPrompt { connection_string: String, nickname: Option, is_wsl: bool, + is_devcontainer: bool, window: &mut Window, cx: &mut Context, ) -> Self { @@ -155,6 +165,7 @@ impl RemoteConnectionPrompt { connection_string: connection_string.into(), nickname: nickname.map(|nickname| nickname.into()), is_wsl, + is_devcontainer, editor: cx.new(|cx| Editor::single_line(window, cx)), status_message: None, cancellation: None, @@ -244,17 +255,16 @@ impl Render for RemoteConnectionPrompt { v_flex() .key_context("PasswordPrompt") - .py_2() - .px_3() + .p_2() .size_full() .text_buffer(cx) .when_some(self.status_message.clone(), |el, status_message| { el.child( h_flex() - .gap_1() + .gap_2() .child( Icon::new(IconName::ArrowCircle) - .size(IconSize::Medium) + .color(Color::Muted) .with_rotate_animation(2), ) .child( @@ -287,15 +297,28 @@ impl RemoteConnectionModal { window: &mut Window, cx: &mut Context, ) -> Self { - let (connection_string, nickname, is_wsl) = match connection_options { - RemoteConnectionOptions::Ssh(options) => { - (options.connection_string(), options.nickname.clone(), false) + let (connection_string, nickname, is_wsl, is_devcontainer) = match connection_options { + RemoteConnectionOptions::Ssh(options) => ( + options.connection_string(), + options.nickname.clone(), + false, + false, + ), + RemoteConnectionOptions::Wsl(options) => { + (options.distro_name.clone(), None, true, false) } - RemoteConnectionOptions::Wsl(options) => (options.distro_name.clone(), None, true), + RemoteConnectionOptions::Docker(options) => (options.name.clone(), None, false, true), }; Self { prompt: cx.new(|cx| { - RemoteConnectionPrompt::new(connection_string, nickname, is_wsl, window, cx) + RemoteConnectionPrompt::new( + connection_string, + nickname, + is_wsl, + is_devcontainer, + window, + cx, + ) }), finished: false, paths, @@ -328,6 +351,7 @@ pub(crate) struct SshConnectionHeader { pub(crate) paths: Vec, pub(crate) nickname: Option, pub(crate) is_wsl: bool, + pub(crate) is_devcontainer: bool, } impl RenderOnce for SshConnectionHeader { @@ -343,9 +367,12 @@ impl RenderOnce for SshConnectionHeader { (self.connection_string, None) }; - let icon = match self.is_wsl { - true => IconName::Linux, - false => IconName::Server, + let icon = if self.is_wsl { + IconName::Linux + } else if self.is_devcontainer { + IconName::Box + } else { + IconName::Server }; h_flex() @@ -388,6 +415,7 @@ impl Render for RemoteConnectionModal { let nickname = self.prompt.read(cx).nickname.clone(); let connection_string = self.prompt.read(cx).connection_string.clone(); let is_wsl = self.prompt.read(cx).is_wsl; + let is_devcontainer = self.prompt.read(cx).is_devcontainer; let theme = cx.theme().clone(); let body_color = theme.colors().editor_background; @@ -407,18 +435,34 @@ impl Render for RemoteConnectionModal { connection_string, nickname, is_wsl, + is_devcontainer, } .render(window, cx), ) .child( div() .w_full() - .rounded_b_lg() .bg(body_color) - .border_t_1() + .border_y_1() .border_color(theme.colors().border_variant) .child(self.prompt.clone()), ) + .child( + div().w_full().py_1().child( + ListItem::new("li-devcontainer-go-back") + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot(Icon::new(IconName::Close).color(Color::Muted)) + .child(Label::new("Cancel")) + .end_slot( + KeyBinding::for_action_in(&menu::Cancel, &self.focus_handle(cx), cx) + .size(rems_from_px(12.)), + ) + .on_click(cx.listener(|this, _, window, cx| { + this.dismiss(&menu::Cancel, window, cx); + })), + ), + ) } } @@ -671,6 +715,9 @@ pub async fn open_remote_project( match connection_options { RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH", RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL", + RemoteConnectionOptions::Docker(_) => { + "Failed to connect to Dev Container" + } }, Some(&format!("{e:#}")), &["Retry", "Cancel"], @@ -727,6 +774,9 @@ pub async fn open_remote_project( match connection_options { RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH", RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL", + RemoteConnectionOptions::Docker(_) => { + "Failed to connect to Dev Container" + } }, Some(&format!("{e:#}")), &["Retry", "Cancel"], diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index 6dff231b30ddde741f69ba9d4e0366517d8e2751..32a4ef1a81a06a8b5968f7941edb4ab8ea0a5111 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -1,4 +1,5 @@ use crate::{ + dev_container::start_dev_container, remote_connections::{ Connection, RemoteConnectionModal, RemoteConnectionPrompt, SshConnection, SshConnectionHeader, SshSettings, connect, determine_paths_with_positions, @@ -24,7 +25,7 @@ use remote::{ remote_client::ConnectionIdentifier, }; use settings::{ - RemoteSettingsContent, Settings as _, SettingsStore, SshProject, update_settings_file, + RemoteProject, RemoteSettingsContent, Settings as _, SettingsStore, update_settings_file, watch_config_file, }; use smol::stream::StreamExt as _; @@ -39,12 +40,13 @@ use std::{ }, }; use ui::{ - IconButtonShape, List, ListItem, ListSeparator, Modal, ModalHeader, Navigable, NavigableEntry, - Section, Tooltip, WithScrollbar, prelude::*, + CommonAnimationExt, IconButtonShape, KeyBinding, List, ListItem, ListSeparator, Modal, + ModalHeader, Navigable, NavigableEntry, Section, Tooltip, WithScrollbar, prelude::*, }; use util::{ ResultExt, paths::{PathStyle, RemotePathBuf}, + rel_path::RelPath, }; use workspace::{ ModalView, OpenOptions, Toast, Workspace, @@ -85,6 +87,39 @@ impl CreateRemoteServer { } } +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +enum DevContainerCreationProgress { + Initial, + Creating, + Error(String), +} + +#[derive(Clone)] +struct CreateRemoteDevContainer { + // 3 Navigable Options + // - Create from devcontainer.json + // - Edit devcontainer.json + // - Go back + entries: [NavigableEntry; 3], + progress: DevContainerCreationProgress, +} + +impl CreateRemoteDevContainer { + fn new(window: &mut Window, cx: &mut Context) -> Self { + let entries = std::array::from_fn(|_| NavigableEntry::focusable(cx)); + entries[0].focus_handle.focus(window); + Self { + entries, + progress: DevContainerCreationProgress::Initial, + } + } + + fn progress(&mut self, progress: DevContainerCreationProgress) -> Self { + self.progress = progress; + self.clone() + } +} + #[cfg(target_os = "windows")] struct AddWslDistro { picker: Entity>, @@ -207,6 +242,11 @@ impl ProjectPicker { RemoteConnectionOptions::Wsl(connection) => ProjectPickerData::Wsl { distro_name: connection.distro_name.clone().into(), }, + RemoteConnectionOptions::Docker(_) => ProjectPickerData::Ssh { + // Not implemented as a project picker at this time + connection_string: "".into(), + nickname: None, + }, }; let _path_task = cx .spawn_in(window, { @@ -259,7 +299,7 @@ impl ProjectPicker { .as_mut() .and_then(|connections| connections.get_mut(index.0)) { - server.projects.insert(SshProject { paths }); + server.projects.insert(RemoteProject { paths }); }; } ServerIndex::Wsl(index) => { @@ -269,7 +309,7 @@ impl ProjectPicker { .as_mut() .and_then(|connections| connections.get_mut(index.0)) { - server.projects.insert(SshProject { paths }); + server.projects.insert(RemoteProject { paths }); }; } } @@ -349,6 +389,7 @@ impl gpui::Render for ProjectPicker { paths: Default::default(), nickname: nickname.clone(), is_wsl: false, + is_devcontainer: false, } .render(window, cx), ProjectPickerData::Wsl { distro_name } => SshConnectionHeader { @@ -356,6 +397,7 @@ impl gpui::Render for ProjectPicker { paths: Default::default(), nickname: None, is_wsl: true, + is_devcontainer: false, } .render(window, cx), }) @@ -406,7 +448,7 @@ impl From for ServerIndex { enum RemoteEntry { Project { open_folder: NavigableEntry, - projects: Vec<(NavigableEntry, SshProject)>, + projects: Vec<(NavigableEntry, RemoteProject)>, configure: NavigableEntry, connection: Connection, index: ServerIndex, @@ -440,6 +482,7 @@ impl RemoteEntry { struct DefaultState { scroll_handle: ScrollHandle, add_new_server: NavigableEntry, + add_new_devcontainer: NavigableEntry, add_new_wsl: NavigableEntry, servers: Vec, } @@ -448,6 +491,7 @@ impl DefaultState { fn new(ssh_config_servers: &BTreeSet, cx: &mut App) -> Self { let handle = ScrollHandle::new(); let add_new_server = NavigableEntry::new(&handle, cx); + let add_new_devcontainer = NavigableEntry::new(&handle, cx); let add_new_wsl = NavigableEntry::new(&handle, cx); let ssh_settings = SshSettings::get_global(cx); @@ -517,6 +561,7 @@ impl DefaultState { Self { scroll_handle: handle, add_new_server, + add_new_devcontainer, add_new_wsl, servers, } @@ -552,6 +597,7 @@ enum Mode { EditNickname(EditNicknameState), ProjectPicker(Entity), CreateRemoteServer(CreateRemoteServer), + CreateRemoteDevContainer(CreateRemoteDevContainer), #[cfg(target_os = "windows")] AddWslDistro(AddWslDistro), } @@ -598,6 +644,27 @@ impl RemoteServerProjects { ) } + /// Creates a new RemoteServerProjects modal that opens directly in dev container creation mode. + /// Used when suggesting dev container connection from toast notification. + pub fn new_dev_container( + fs: Arc, + window: &mut Window, + workspace: WeakEntity, + cx: &mut Context, + ) -> Self { + Self::new_inner( + Mode::CreateRemoteDevContainer( + CreateRemoteDevContainer::new(window, cx) + .progress(DevContainerCreationProgress::Creating), + ), + false, + fs, + window, + workspace, + cx, + ) + } + fn new_inner( mode: Mode, create_new_window: bool, @@ -703,6 +770,7 @@ impl RemoteServerProjects { connection_options.connection_string(), connection_options.nickname.clone(), false, + false, window, cx, ) @@ -778,6 +846,7 @@ impl RemoteServerProjects { connection_options.distro_name.clone(), None, true, + false, window, cx, ) @@ -862,6 +931,15 @@ impl RemoteServerProjects { cx.notify(); } + fn view_in_progress_dev_container(&mut self, window: &mut Window, cx: &mut Context) { + self.mode = Mode::CreateRemoteDevContainer( + CreateRemoteDevContainer::new(window, cx) + .progress(DevContainerCreationProgress::Creating), + ); + self.focus_handle(cx).focus(window); + cx.notify(); + } + fn create_remote_project( &mut self, index: ServerIndex, @@ -981,6 +1059,7 @@ impl RemoteServerProjects { self.create_ssh_server(state.address_editor.clone(), window, cx); } + Mode::CreateRemoteDevContainer(_) => {} Mode::EditNickname(state) => { let text = Some(state.editor.read(cx).text(cx)).filter(|text| !text.is_empty()); let index = state.index; @@ -1024,14 +1103,14 @@ impl RemoteServerProjects { } } - fn render_ssh_connection( + fn render_remote_connection( &mut self, ix: usize, - ssh_server: RemoteEntry, + remote_server: RemoteEntry, window: &mut Window, cx: &mut Context, ) -> impl IntoElement { - let connection = ssh_server.connection().into_owned(); + let connection = remote_server.connection().into_owned(); let (main_label, aux_label, is_wsl) = match &connection { Connection::Ssh(connection) => { @@ -1045,6 +1124,9 @@ impl RemoteServerProjects { Connection::Wsl(wsl_connection_options) => { (wsl_connection_options.distro_name.clone(), None, true) } + Connection::DevContainer(dev_container_options) => { + (dev_container_options.name.clone(), None, false) + } }; v_flex() .w_full() @@ -1082,7 +1164,7 @@ impl RemoteServerProjects { }), ), ) - .child(match &ssh_server { + .child(match &remote_server { RemoteEntry::Project { open_folder, projects, @@ -1094,9 +1176,9 @@ impl RemoteServerProjects { List::new() .empty_message("No projects.") .children(projects.iter().enumerate().map(|(pix, p)| { - v_flex().gap_0p5().child(self.render_ssh_project( + v_flex().gap_0p5().child(self.render_remote_project( index, - ssh_server.clone(), + remote_server.clone(), pix, p, window, @@ -1222,12 +1304,12 @@ impl RemoteServerProjects { }) } - fn render_ssh_project( + fn render_remote_project( &mut self, server_ix: ServerIndex, server: RemoteEntry, ix: usize, - (navigation, project): &(NavigableEntry, SshProject), + (navigation, project): &(NavigableEntry, RemoteProject), window: &mut Window, cx: &mut Context, ) -> impl IntoElement { @@ -1372,7 +1454,7 @@ impl RemoteServerProjects { fn delete_remote_project( &mut self, server: ServerIndex, - project: &SshProject, + project: &RemoteProject, cx: &mut Context, ) { match server { @@ -1388,7 +1470,7 @@ impl RemoteServerProjects { fn delete_ssh_project( &mut self, server: SshServerIndex, - project: &SshProject, + project: &RemoteProject, cx: &mut Context, ) { let project = project.clone(); @@ -1406,7 +1488,7 @@ impl RemoteServerProjects { fn delete_wsl_project( &mut self, server: WslServerIndex, - project: &SshProject, + project: &RemoteProject, cx: &mut Context, ) { let project = project.clone(); @@ -1451,6 +1533,342 @@ impl RemoteServerProjects { }); } + fn edit_in_dev_container_json(&mut self, window: &mut Window, cx: &mut Context) { + let Some(workspace) = self.workspace.upgrade() else { + cx.emit(DismissEvent); + cx.notify(); + return; + }; + + workspace.update(cx, |workspace, cx| { + let project = workspace.project().clone(); + + let worktree = project + .read(cx) + .visible_worktrees(cx) + .find_map(|tree| tree.read(cx).root_entry()?.is_dir().then_some(tree)); + + if let Some(worktree) = worktree { + let tree_id = worktree.read(cx).id(); + let devcontainer_path = RelPath::unix(".devcontainer/devcontainer.json").unwrap(); + cx.spawn_in(window, async move |workspace, cx| { + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path( + (tree_id, devcontainer_path), + None, + true, + window, + cx, + ) + })? + .await + }) + .detach(); + } else { + return; + } + }); + cx.emit(DismissEvent); + cx.notify(); + } + + fn open_dev_container(&self, window: &mut Window, cx: &mut Context) { + let Some(app_state) = self + .workspace + .read_with(cx, |workspace, _| workspace.app_state().clone()) + .log_err() + else { + return; + }; + + let replace_window = window.window_handle().downcast::(); + + cx.spawn_in(window, async move |entity, cx| { + let (connection, starting_dir) = + match start_dev_container(cx, app_state.node_runtime.clone()).await { + Ok((c, s)) => (c, s), + Err(e) => { + log::error!("Failed to start dev container: {:?}", e); + entity + .update_in(cx, |remote_server_projects, window, cx| { + remote_server_projects.mode = Mode::CreateRemoteDevContainer( + CreateRemoteDevContainer::new(window, cx).progress( + DevContainerCreationProgress::Error(format!("{:?}", e)), + ), + ); + }) + .log_err(); + return; + } + }; + entity + .update(cx, |_, cx| { + cx.emit(DismissEvent); + }) + .log_err(); + + let result = open_remote_project( + connection.into(), + vec![starting_dir].into_iter().map(PathBuf::from).collect(), + app_state, + OpenOptions { + replace_window, + ..OpenOptions::default() + }, + cx, + ) + .await; + if let Err(e) = result { + log::error!("Failed to connect: {e:#}"); + cx.prompt( + gpui::PromptLevel::Critical, + "Failed to connect", + Some(&e.to_string()), + &["Ok"], + ) + .await + .ok(); + } + }) + .detach(); + } + + fn render_create_dev_container( + &self, + state: &CreateRemoteDevContainer, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + match &state.progress { + DevContainerCreationProgress::Error(message) => { + self.focus_handle(cx).focus(window); + return div() + .track_focus(&self.focus_handle(cx)) + .size_full() + .child( + v_flex() + .py_1() + .child( + ListItem::new("Error") + .inset(true) + .selectable(false) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot(Icon::new(IconName::XCircle).color(Color::Error)) + .child(Label::new("Error Creating Dev Container:")) + .child(Label::new(message).buffer_font(cx)), + ) + .child(ListSeparator) + .child( + div() + .id("devcontainer-go-back") + .track_focus(&state.entries[0].focus_handle) + .on_action(cx.listener( + |this, _: &menu::Confirm, window, cx| { + this.mode = + Mode::default_mode(&this.ssh_config_servers, cx); + cx.focus_self(window); + cx.notify(); + }, + )) + .child( + ListItem::new("li-devcontainer-go-back") + .toggle_state( + state.entries[0] + .focus_handle + .contains_focused(window, cx), + ) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot( + Icon::new(IconName::ArrowLeft).color(Color::Muted), + ) + .child(Label::new("Go Back")) + .end_slot( + KeyBinding::for_action_in( + &menu::Cancel, + &self.focus_handle, + cx, + ) + .size(rems_from_px(12.)), + ) + .on_click(cx.listener(|this, _, window, cx| { + let state = + CreateRemoteDevContainer::new(window, cx); + this.mode = Mode::CreateRemoteDevContainer(state); + + cx.notify(); + })), + ), + ), + ) + .into_any_element(); + } + _ => {} + }; + + let mut view = Navigable::new( + div() + .track_focus(&self.focus_handle(cx)) + .size_full() + .child( + v_flex() + .pb_1() + .child( + ModalHeader::new() + .child(Headline::new("Dev Containers").size(HeadlineSize::XSmall)), + ) + .child(ListSeparator) + .child( + div() + .id("confirm-create-from-devcontainer-json") + .track_focus(&state.entries[0].focus_handle) + .on_action(cx.listener({ + move |this, _: &menu::Confirm, window, cx| { + this.open_dev_container(window, cx); + this.view_in_progress_dev_container(window, cx); + } + })) + .map(|this| { + if state.progress == DevContainerCreationProgress::Creating { + this.child( + ListItem::new("creating") + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .disabled(true) + .start_slot( + Icon::new(IconName::ArrowCircle) + .color(Color::Muted) + .with_rotate_animation(2), + ) + .child( + h_flex() + .opacity(0.6) + .gap_1() + .child(Label::new("Creating From")) + .child( + Label::new("devcontainer.json") + .buffer_font(cx), + ) + .child(LoadingLabel::new("")), + ), + ) + } else { + this.child( + ListItem::new( + "li-confirm-create-from-devcontainer-json", + ) + .toggle_state( + state.entries[0] + .focus_handle + .contains_focused(window, cx), + ) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot( + Icon::new(IconName::Plus).color(Color::Muted), + ) + .child( + h_flex() + .gap_1() + .child(Label::new("Open or Create New From")) + .child( + Label::new("devcontainer.json") + .buffer_font(cx), + ), + ) + .on_click( + cx.listener({ + move |this, _, window, cx| { + this.open_dev_container(window, cx); + this.view_in_progress_dev_container( + window, cx, + ); + cx.notify(); + } + }), + ), + ) + } + }), + ) + .child( + div() + .id("edit-devcontainer-json") + .track_focus(&state.entries[1].focus_handle) + .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| { + this.edit_in_dev_container_json(window, cx); + })) + .child( + ListItem::new("li-edit-devcontainer-json") + .toggle_state( + state.entries[1] + .focus_handle + .contains_focused(window, cx), + ) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot(Icon::new(IconName::Pencil).color(Color::Muted)) + .child( + h_flex().gap_1().child(Label::new("Edit")).child( + Label::new("devcontainer.json").buffer_font(cx), + ), + ) + .on_click(cx.listener(move |this, _, window, cx| { + this.edit_in_dev_container_json(window, cx); + })), + ), + ) + .child(ListSeparator) + .child( + div() + .id("devcontainer-go-back") + .track_focus(&state.entries[2].focus_handle) + .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| { + this.mode = Mode::default_mode(&this.ssh_config_servers, cx); + cx.focus_self(window); + cx.notify(); + })) + .child( + ListItem::new("li-devcontainer-go-back") + .toggle_state( + state.entries[2] + .focus_handle + .contains_focused(window, cx), + ) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot( + Icon::new(IconName::ArrowLeft).color(Color::Muted), + ) + .child(Label::new("Go Back")) + .end_slot( + KeyBinding::for_action_in( + &menu::Cancel, + &self.focus_handle, + cx, + ) + .size(rems_from_px(12.)), + ) + .on_click(cx.listener(|this, _, window, cx| { + this.mode = + Mode::default_mode(&this.ssh_config_servers, cx); + cx.focus_self(window); + cx.notify() + })), + ), + ), + ) + .into_any_element(), + ); + + view = view.entry(state.entries[0].clone()); + view = view.entry(state.entries[1].clone()); + view = view.entry(state.entries[2].clone()); + + view.render(window, cx).into_any_element() + } + fn render_create_remote_server( &self, state: &CreateRemoteServer, @@ -1571,6 +1989,7 @@ impl RemoteServerProjects { paths: Default::default(), nickname: connection.nickname.clone().map(|s| s.into()), is_wsl: false, + is_devcontainer: false, } .render(window, cx) .into_any_element(), @@ -1579,6 +1998,7 @@ impl RemoteServerProjects { paths: Default::default(), nickname: None, is_wsl: true, + is_devcontainer: false, } .render(window, cx) .into_any_element(), @@ -1917,6 +2337,7 @@ impl RemoteServerProjects { paths: Default::default(), nickname, is_wsl: false, + is_devcontainer: false, } .render(window, cx), ) @@ -1998,7 +2419,7 @@ impl RemoteServerProjects { .track_focus(&state.add_new_server.focus_handle) .anchor_scroll(state.add_new_server.scroll_anchor.clone()) .child( - ListItem::new("register-remove-server-button") + ListItem::new("register-remote-server-button") .toggle_state( state .add_new_server @@ -2008,7 +2429,7 @@ impl RemoteServerProjects { .inset(true) .spacing(ui::ListItemSpacing::Sparse) .start_slot(Icon::new(IconName::Plus).color(Color::Muted)) - .child(Label::new("Connect New Server")) + .child(Label::new("Connect SSH Server")) .on_click(cx.listener(|this, _, window, cx| { let state = CreateRemoteServer::new(window, cx); this.mode = Mode::CreateRemoteServer(state); @@ -2023,6 +2444,36 @@ impl RemoteServerProjects { cx.notify(); })); + let connect_dev_container_button = div() + .id("connect-new-dev-container") + .track_focus(&state.add_new_devcontainer.focus_handle) + .anchor_scroll(state.add_new_devcontainer.scroll_anchor.clone()) + .child( + ListItem::new("register-dev-container-button") + .toggle_state( + state + .add_new_devcontainer + .focus_handle + .contains_focused(window, cx), + ) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot(Icon::new(IconName::Plus).color(Color::Muted)) + .child(Label::new("Connect Dev Container")) + .on_click(cx.listener(|this, _, window, cx| { + let state = CreateRemoteDevContainer::new(window, cx); + this.mode = Mode::CreateRemoteDevContainer(state); + + cx.notify(); + })), + ) + .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| { + let state = CreateRemoteDevContainer::new(window, cx); + this.mode = Mode::CreateRemoteDevContainer(state); + + cx.notify(); + })); + #[cfg(target_os = "windows")] let wsl_connect_button = div() .id("wsl-connect-new-server") @@ -2049,13 +2500,30 @@ impl RemoteServerProjects { cx.notify(); })); + let has_open_project = self + .workspace + .upgrade() + .map(|workspace| { + workspace + .read(cx) + .project() + .read(cx) + .visible_worktrees(cx) + .next() + .is_some() + }) + .unwrap_or(false); + let modal_section = v_flex() .track_focus(&self.focus_handle(cx)) .id("ssh-server-list") .overflow_y_scroll() .track_scroll(&state.scroll_handle) .size_full() - .child(connect_button); + .child(connect_button) + .when(has_open_project, |this| { + this.child(connect_dev_container_button) + }); #[cfg(target_os = "windows")] let modal_section = modal_section.child(wsl_connect_button); @@ -2067,17 +2535,20 @@ impl RemoteServerProjects { .child( List::new() .empty_message( - v_flex() + h_flex() + .size_full() + .p_2() + .justify_center() + .border_t_1() + .border_color(cx.theme().colors().border_variant) .child( - div().px_3().child( - Label::new("No remote servers registered yet.") - .color(Color::Muted), - ), + Label::new("No remote servers registered yet.") + .color(Color::Muted), ) .into_any_element(), ) .children(state.servers.iter().enumerate().map(|(ix, connection)| { - self.render_ssh_connection(ix, connection.clone(), window, cx) + self.render_remote_connection(ix, connection.clone(), window, cx) .into_any_element() })), ) @@ -2085,6 +2556,10 @@ impl RemoteServerProjects { ) .entry(state.add_new_server.clone()); + if has_open_project { + modal_section = modal_section.entry(state.add_new_devcontainer.clone()); + } + if cfg!(target_os = "windows") { modal_section = modal_section.entry(state.add_new_wsl.clone()); } @@ -2297,6 +2772,9 @@ impl Render for RemoteServerProjects { Mode::CreateRemoteServer(state) => self .render_create_remote_server(state, window, cx) .into_any_element(), + Mode::CreateRemoteDevContainer(state) => self + .render_create_dev_container(state, window, cx) + .into_any_element(), Mode::EditNickname(state) => self .render_edit_nickname(state, window, cx) .into_any_element(), diff --git a/crates/remote/src/remote.rs b/crates/remote/src/remote.rs index 783dde16acb350367ed82243e138e5c58f64224b..51b71c988a6dc57e875b3baa28103bef0d8fd729 100644 --- a/crates/remote/src/remote.rs +++ b/crates/remote/src/remote.rs @@ -10,5 +10,6 @@ pub use remote_client::{ ConnectionIdentifier, ConnectionState, RemoteClient, RemoteClientDelegate, RemoteClientEvent, RemoteConnection, RemoteConnectionOptions, RemotePlatform, connect, }; +pub use transport::docker::DockerConnectionOptions; pub use transport::ssh::{SshConnectionOptions, SshPortForwardOption}; pub use transport::wsl::WslConnectionOptions; diff --git a/crates/remote/src/remote_client.rs b/crates/remote/src/remote_client.rs index b0f9914c90545263a830ec034512a7e423109409..e8fa4fe4a3e727e823fc5912ddf3e940adf0f78f 100644 --- a/crates/remote/src/remote_client.rs +++ b/crates/remote/src/remote_client.rs @@ -3,6 +3,7 @@ use crate::{ protocol::MessageId, proxy::ProxyLaunchError, transport::{ + docker::{DockerConnectionOptions, DockerExecConnection}, ssh::SshRemoteConnection, wsl::{WslConnectionOptions, WslRemoteConnection}, }, @@ -1042,6 +1043,11 @@ impl ConnectionPool { .await .map(|connection| Arc::new(connection) as Arc) } + RemoteConnectionOptions::Docker(opts) => { + DockerExecConnection::new(opts, delegate, cx) + .await + .map(|connection| Arc::new(connection) as Arc) + } }; cx.update_global(|pool: &mut Self, _| { @@ -1077,6 +1083,7 @@ impl ConnectionPool { pub enum RemoteConnectionOptions { Ssh(SshConnectionOptions), Wsl(WslConnectionOptions), + Docker(DockerConnectionOptions), } impl RemoteConnectionOptions { @@ -1084,6 +1091,7 @@ impl RemoteConnectionOptions { match self { RemoteConnectionOptions::Ssh(opts) => opts.host.clone(), RemoteConnectionOptions::Wsl(opts) => opts.distro_name.clone(), + RemoteConnectionOptions::Docker(opts) => opts.name.clone(), } } } diff --git a/crates/remote/src/transport.rs b/crates/remote/src/transport.rs index 1976be5656d7a227541d7adf6a36d91b5bfdcc59..4cafbf60eec338addbb43e46d156960621301ab0 100644 --- a/crates/remote/src/transport.rs +++ b/crates/remote/src/transport.rs @@ -12,6 +12,7 @@ use gpui::{AppContext as _, AsyncApp, Task}; use rpc::proto::Envelope; use smol::process::Child; +pub mod docker; pub mod ssh; pub mod wsl; @@ -64,15 +65,15 @@ fn parse_shell(output: &str, fallback_shell: &str) -> String { } fn handle_rpc_messages_over_child_process_stdio( - mut ssh_proxy_process: Child, + mut remote_proxy_process: Child, incoming_tx: UnboundedSender, mut outgoing_rx: UnboundedReceiver, mut connection_activity_tx: Sender<()>, cx: &AsyncApp, ) -> Task> { - let mut child_stderr = ssh_proxy_process.stderr.take().unwrap(); - let mut child_stdout = ssh_proxy_process.stdout.take().unwrap(); - let mut child_stdin = ssh_proxy_process.stdin.take().unwrap(); + let mut child_stderr = remote_proxy_process.stderr.take().unwrap(); + let mut child_stdout = remote_proxy_process.stdout.take().unwrap(); + let mut child_stdin = remote_proxy_process.stdin.take().unwrap(); let mut stdin_buffer = Vec::new(); let mut stdout_buffer = Vec::new(); @@ -156,7 +157,7 @@ fn handle_rpc_messages_over_child_process_stdio( result.context("stderr") } }; - let status = ssh_proxy_process.status().await?.code().unwrap_or(1); + let status = remote_proxy_process.status().await?.code().unwrap_or(1); match result { Ok(_) => Ok(status), Err(error) => Err(error), diff --git a/crates/remote/src/transport/docker.rs b/crates/remote/src/transport/docker.rs new file mode 100644 index 0000000000000000000000000000000000000000..09f5935ec621260e933f11f46aa57493a31ace6d --- /dev/null +++ b/crates/remote/src/transport/docker.rs @@ -0,0 +1,757 @@ +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use async_trait::async_trait; +use collections::HashMap; +use parking_lot::Mutex; +use release_channel::{AppCommitSha, AppVersion, ReleaseChannel}; +use semver::Version as SemanticVersion; +use std::time::Instant; +use std::{ + path::{Path, PathBuf}, + process::Stdio, + sync::Arc, +}; +use util::ResultExt; +use util::shell::ShellKind; +use util::{ + paths::{PathStyle, RemotePathBuf}, + rel_path::RelPath, +}; + +use futures::channel::mpsc::{Sender, UnboundedReceiver, UnboundedSender}; +use gpui::{App, AppContext, AsyncApp, Task}; +use rpc::proto::Envelope; + +use crate::{ + RemoteClientDelegate, RemoteConnection, RemoteConnectionOptions, RemotePlatform, + remote_client::CommandTemplate, +}; + +#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] +pub struct DockerConnectionOptions { + pub name: String, + pub container_id: String, + pub upload_binary_over_docker_exec: bool, +} + +pub(crate) struct DockerExecConnection { + proxy_process: Mutex>, + remote_dir_for_server: String, + remote_binary_relpath: Option>, + connection_options: DockerConnectionOptions, + remote_platform: Option, + path_style: Option, + shell: Option, +} + +impl DockerExecConnection { + pub async fn new( + connection_options: DockerConnectionOptions, + delegate: Arc, + cx: &mut AsyncApp, + ) -> Result { + let mut this = Self { + proxy_process: Mutex::new(None), + remote_dir_for_server: "/".to_string(), + remote_binary_relpath: None, + connection_options, + remote_platform: None, + path_style: None, + shell: None, + }; + let (release_channel, version, commit) = cx.update(|cx| { + ( + ReleaseChannel::global(cx), + AppVersion::global(cx), + AppCommitSha::try_global(cx), + ) + })?; + let remote_platform = this.check_remote_platform().await?; + + this.path_style = match remote_platform.os { + "windows" => Some(PathStyle::Windows), + _ => Some(PathStyle::Posix), + }; + + this.remote_platform = Some(remote_platform); + + this.shell = Some(this.discover_shell().await); + + this.remote_dir_for_server = this.docker_user_home_dir().await?.trim().to_string(); + + this.remote_binary_relpath = Some( + this.ensure_server_binary( + &delegate, + release_channel, + version, + &this.remote_dir_for_server, + commit, + cx, + ) + .await?, + ); + + Ok(this) + } + + async fn discover_shell(&self) -> String { + let default_shell = "sh"; + match self + .run_docker_exec("sh", None, &Default::default(), &["-c", "echo $SHELL"]) + .await + { + Ok(shell) => match shell.trim() { + "" => { + log::error!("$SHELL is not set, falling back to {default_shell}"); + default_shell.to_owned() + } + shell => shell.to_owned(), + }, + Err(e) => { + log::error!("Failed to get shell: {e}"); + default_shell.to_owned() + } + } + } + + async fn check_remote_platform(&self) -> Result { + let uname = self + .run_docker_exec("uname", None, &Default::default(), &["-sm"]) + .await?; + let Some((os, arch)) = uname.split_once(" ") else { + anyhow::bail!("unknown uname: {uname:?}") + }; + + let os = match os.trim() { + "Darwin" => "macos", + "Linux" => "linux", + _ => anyhow::bail!( + "Prebuilt remote servers are not yet available for {os:?}. See https://zed.dev/docs/remote-development" + ), + }; + // exclude armv5,6,7 as they are 32-bit. + let arch = if arch.starts_with("armv8") + || arch.starts_with("armv9") + || arch.starts_with("arm64") + || arch.starts_with("aarch64") + { + "aarch64" + } else if arch.starts_with("x86") { + "x86_64" + } else { + anyhow::bail!( + "Prebuilt remote servers are not yet available for {arch:?}. See https://zed.dev/docs/remote-development" + ) + }; + + Ok(RemotePlatform { os, arch }) + } + + async fn ensure_server_binary( + &self, + delegate: &Arc, + release_channel: ReleaseChannel, + version: SemanticVersion, + remote_dir_for_server: &str, + commit: Option, + cx: &mut AsyncApp, + ) -> Result> { + let remote_platform = if self.remote_platform.is_some() { + self.remote_platform.unwrap() + } else { + anyhow::bail!("No remote platform defined; cannot proceed.") + }; + + let version_str = match release_channel { + ReleaseChannel::Nightly => { + let commit = commit.map(|s| s.full()).unwrap_or_default(); + format!("{}-{}", version, commit) + } + ReleaseChannel::Dev => "build".to_string(), + _ => version.to_string(), + }; + let binary_name = format!( + "zed-remote-server-{}-{}", + release_channel.dev_name(), + version_str + ); + let dst_path = + paths::remote_server_dir_relative().join(RelPath::unix(&binary_name).unwrap()); + + #[cfg(debug_assertions)] + if let Some(remote_server_path) = + super::build_remote_server_from_source(&remote_platform, delegate.as_ref(), cx).await? + { + let tmp_path = paths::remote_server_dir_relative().join( + RelPath::unix(&format!( + "download-{}-{}", + std::process::id(), + remote_server_path.file_name().unwrap().to_string_lossy() + )) + .unwrap(), + ); + self.upload_local_server_binary( + &remote_server_path, + &tmp_path, + &remote_dir_for_server, + delegate, + cx, + ) + .await?; + self.extract_server_binary(&dst_path, &tmp_path, &remote_dir_for_server, delegate, cx) + .await?; + return Ok(dst_path); + } + + if self + .run_docker_exec( + &dst_path.display(self.path_style()), + Some(&remote_dir_for_server), + &Default::default(), + &["version"], + ) + .await + .is_ok() + { + return Ok(dst_path); + } + + let wanted_version = cx.update(|cx| match release_channel { + ReleaseChannel::Nightly => Ok(None), + ReleaseChannel::Dev => { + anyhow::bail!( + "ZED_BUILD_REMOTE_SERVER is not set and no remote server exists at ({:?})", + dst_path + ) + } + _ => Ok(Some(AppVersion::global(cx))), + })??; + + let tmp_path_gz = paths::remote_server_dir_relative().join( + RelPath::unix(&format!( + "{}-download-{}.gz", + binary_name, + std::process::id() + )) + .unwrap(), + ); + if !self.connection_options.upload_binary_over_docker_exec + && let Some(url) = delegate + .get_download_url(remote_platform, release_channel, wanted_version.clone(), cx) + .await? + { + match self + .download_binary_on_server(&url, &tmp_path_gz, &remote_dir_for_server, delegate, cx) + .await + { + Ok(_) => { + self.extract_server_binary( + &dst_path, + &tmp_path_gz, + &remote_dir_for_server, + delegate, + cx, + ) + .await + .context("extracting server binary")?; + return Ok(dst_path); + } + Err(e) => { + log::error!( + "Failed to download binary on server, attempting to download locally and then upload it the server: {e:#}", + ) + } + } + } + + let src_path = delegate + .download_server_binary_locally(remote_platform, release_channel, wanted_version, cx) + .await + .context("downloading server binary locally")?; + self.upload_local_server_binary( + &src_path, + &tmp_path_gz, + &remote_dir_for_server, + delegate, + cx, + ) + .await + .context("uploading server binary")?; + self.extract_server_binary( + &dst_path, + &tmp_path_gz, + &remote_dir_for_server, + delegate, + cx, + ) + .await + .context("extracting server binary")?; + Ok(dst_path) + } + + async fn docker_user_home_dir(&self) -> Result { + let inner_program = self.shell(); + self.run_docker_exec( + &inner_program, + None, + &Default::default(), + &["-c", "echo $HOME"], + ) + .await + } + + async fn extract_server_binary( + &self, + dst_path: &RelPath, + tmp_path: &RelPath, + remote_dir_for_server: &str, + delegate: &Arc, + cx: &mut AsyncApp, + ) -> Result<()> { + delegate.set_status(Some("Extracting remote development server"), cx); + let server_mode = 0o755; + + let shell_kind = ShellKind::Posix; + let orig_tmp_path = tmp_path.display(self.path_style()); + let server_mode = format!("{:o}", server_mode); + let server_mode = shell_kind + .try_quote(&server_mode) + .context("shell quoting")?; + let dst_path = dst_path.display(self.path_style()); + let dst_path = shell_kind.try_quote(&dst_path).context("shell quoting")?; + let script = if let Some(tmp_path) = orig_tmp_path.strip_suffix(".gz") { + let orig_tmp_path = shell_kind + .try_quote(&orig_tmp_path) + .context("shell quoting")?; + let tmp_path = shell_kind.try_quote(&tmp_path).context("shell quoting")?; + format!( + "gunzip -f {orig_tmp_path} && chmod {server_mode} {tmp_path} && mv {tmp_path} {dst_path}", + ) + } else { + let orig_tmp_path = shell_kind + .try_quote(&orig_tmp_path) + .context("shell quoting")?; + format!("chmod {server_mode} {orig_tmp_path} && mv {orig_tmp_path} {dst_path}",) + }; + let args = shell_kind.args_for_shell(false, script.to_string()); + self.run_docker_exec( + "sh", + Some(&remote_dir_for_server), + &Default::default(), + &args, + ) + .await + .log_err(); + Ok(()) + } + + async fn upload_local_server_binary( + &self, + src_path: &Path, + tmp_path_gz: &RelPath, + remote_dir_for_server: &str, + delegate: &Arc, + cx: &mut AsyncApp, + ) -> Result<()> { + if let Some(parent) = tmp_path_gz.parent() { + self.run_docker_exec( + "mkdir", + Some(remote_dir_for_server), + &Default::default(), + &["-p", parent.display(self.path_style()).as_ref()], + ) + .await?; + } + + let src_stat = smol::fs::metadata(&src_path).await?; + let size = src_stat.len(); + + let t0 = Instant::now(); + delegate.set_status(Some("Uploading remote development server"), cx); + log::info!( + "uploading remote development server to {:?} ({}kb)", + tmp_path_gz, + size / 1024 + ); + self.upload_file(src_path, tmp_path_gz, remote_dir_for_server) + .await + .context("failed to upload server binary")?; + log::info!("uploaded remote development server in {:?}", t0.elapsed()); + Ok(()) + } + + async fn upload_file( + &self, + src_path: &Path, + dest_path: &RelPath, + remote_dir_for_server: &str, + ) -> Result<()> { + log::debug!("uploading file {:?} to {:?}", src_path, dest_path); + + let src_path_display = src_path.display().to_string(); + let dest_path_str = dest_path.display(self.path_style()); + + let mut command = util::command::new_smol_command("docker"); + command.arg("cp"); + command.arg("-a"); + command.arg(&src_path_display); + command.arg(format!( + "{}:{}/{}", + &self.connection_options.container_id, remote_dir_for_server, dest_path_str + )); + + let output = command.output().await?; + + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + log::debug!( + "failed to upload file via docker cp {src_path_display} -> {dest_path_str}: {stderr}", + ); + anyhow::bail!( + "failed to upload file via docker cp {} -> {}: {}", + src_path_display, + dest_path_str, + stderr, + ); + } + + async fn run_docker_command( + &self, + subcommand: &str, + args: &[impl AsRef], + ) -> Result { + let mut command = util::command::new_smol_command("docker"); + command.arg(subcommand); + for arg in args { + command.arg(arg.as_ref()); + } + let output = command.output().await?; + anyhow::ensure!( + output.status.success(), + "failed to run command {command:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } + + async fn run_docker_exec( + &self, + inner_program: &str, + working_directory: Option<&str>, + env: &HashMap, + program_args: &[impl AsRef], + ) -> Result { + let mut args = match working_directory { + Some(dir) => vec!["-w".to_string(), dir.to_string()], + None => vec![], + }; + + for (k, v) in env.iter() { + args.push("-e".to_string()); + let env_declaration = format!("{}={}", k, v); + args.push(env_declaration); + } + + args.push(self.connection_options.container_id.clone()); + args.push(inner_program.to_string()); + + for arg in program_args { + args.push(arg.as_ref().to_owned()); + } + self.run_docker_command("exec", args.as_ref()).await + } + + async fn download_binary_on_server( + &self, + url: &str, + tmp_path_gz: &RelPath, + remote_dir_for_server: &str, + delegate: &Arc, + cx: &mut AsyncApp, + ) -> Result<()> { + if let Some(parent) = tmp_path_gz.parent() { + self.run_docker_exec( + "mkdir", + Some(remote_dir_for_server), + &Default::default(), + &["-p", parent.display(self.path_style()).as_ref()], + ) + .await?; + } + + delegate.set_status(Some("Downloading remote development server on host"), cx); + + match self + .run_docker_exec( + "curl", + Some(remote_dir_for_server), + &Default::default(), + &[ + "-f", + "-L", + url, + "-o", + &tmp_path_gz.display(self.path_style()), + ], + ) + .await + { + Ok(_) => {} + Err(e) => { + if self + .run_docker_exec("which", None, &Default::default(), &["curl"]) + .await + .is_ok() + { + return Err(e); + } + + log::info!("curl is not available, trying wget"); + match self + .run_docker_exec( + "wget", + Some(remote_dir_for_server), + &Default::default(), + &[url, "-O", &tmp_path_gz.display(self.path_style())], + ) + .await + { + Ok(_) => {} + Err(e) => { + if self + .run_docker_exec("which", None, &Default::default(), &["wget"]) + .await + .is_ok() + { + return Err(e); + } else { + anyhow::bail!("Neither curl nor wget is available"); + } + } + } + } + } + Ok(()) + } + + fn kill_inner(&self) -> Result<()> { + if let Some(pid) = self.proxy_process.lock().take() { + if let Ok(_) = util::command::new_smol_command("kill") + .arg(pid.to_string()) + .spawn() + { + Ok(()) + } else { + Err(anyhow::anyhow!("Failed to kill process")) + } + } else { + Ok(()) + } + } +} + +#[async_trait(?Send)] +impl RemoteConnection for DockerExecConnection { + fn has_wsl_interop(&self) -> bool { + false + } + fn start_proxy( + &self, + unique_identifier: String, + reconnect: bool, + incoming_tx: UnboundedSender, + outgoing_rx: UnboundedReceiver, + connection_activity_tx: Sender<()>, + delegate: Arc, + cx: &mut AsyncApp, + ) -> Task> { + // We'll try connecting anew every time we open a devcontainer, so proactively try to kill any old connections. + if !self.has_been_killed() { + if let Err(e) = self.kill_inner() { + return Task::ready(Err(e)); + }; + } + + delegate.set_status(Some("Starting proxy"), cx); + + let Some(remote_binary_relpath) = self.remote_binary_relpath.clone() else { + return Task::ready(Err(anyhow!("Remote binary path not set"))); + }; + + let mut docker_args = vec![ + "exec".to_string(), + "-w".to_string(), + self.remote_dir_for_server.clone(), + "-i".to_string(), + self.connection_options.container_id.to_string(), + ]; + for env_var in ["RUST_LOG", "RUST_BACKTRACE", "ZED_GENERATE_MINIDUMPS"] { + if let Some(value) = std::env::var(env_var).ok() { + docker_args.push("-e".to_string()); + docker_args.push(format!("{}='{}'", env_var, value)); + } + } + let val = remote_binary_relpath + .display(self.path_style()) + .into_owned(); + docker_args.push(val); + docker_args.push("proxy".to_string()); + docker_args.push("--identifier".to_string()); + docker_args.push(unique_identifier); + if reconnect { + docker_args.push("--reconnect".to_string()); + } + let mut command = util::command::new_smol_command("docker"); + command + .kill_on_drop(true) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .args(docker_args); + + let Ok(child) = command.spawn() else { + return Task::ready(Err(anyhow::anyhow!( + "Failed to start remote server process" + ))); + }; + + let mut proxy_process = self.proxy_process.lock(); + *proxy_process = Some(child.id()); + + super::handle_rpc_messages_over_child_process_stdio( + child, + incoming_tx, + outgoing_rx, + connection_activity_tx, + cx, + ) + } + + fn upload_directory( + &self, + src_path: PathBuf, + dest_path: RemotePathBuf, + cx: &App, + ) -> Task> { + let dest_path_str = dest_path.to_string(); + let src_path_display = src_path.display().to_string(); + + let mut command = util::command::new_smol_command("docker"); + command.arg("cp"); + command.arg("-a"); // Archive mode is required to assign the file ownership to the default docker exec user + command.arg(src_path_display); + command.arg(format!( + "{}:{}", + self.connection_options.container_id, dest_path_str + )); + + cx.background_spawn(async move { + let output = command.output().await?; + + if output.status.success() { + Ok(()) + } else { + Err(anyhow::anyhow!("Failed to upload directory")) + } + }) + } + + async fn kill(&self) -> Result<()> { + self.kill_inner() + } + + fn has_been_killed(&self) -> bool { + self.proxy_process.lock().is_none() + } + + fn build_command( + &self, + program: Option, + args: &[String], + env: &HashMap, + working_dir: Option, + _port_forward: Option<(u16, String, u16)>, + ) -> Result { + let mut parsed_working_dir = None; + + let path_style = self.path_style(); + + if let Some(working_dir) = working_dir { + let working_dir = RemotePathBuf::new(working_dir, path_style).to_string(); + + const TILDE_PREFIX: &'static str = "~/"; + if working_dir.starts_with(TILDE_PREFIX) { + let working_dir = working_dir.trim_start_matches("~").trim_start_matches("/"); + parsed_working_dir = Some(format!("$HOME/{working_dir}")); + } else { + parsed_working_dir = Some(working_dir); + } + } + + let mut inner_program = Vec::new(); + + if let Some(program) = program { + inner_program.push(program); + for arg in args { + inner_program.push(arg.clone()); + } + } else { + inner_program.push(self.shell()); + inner_program.push("-l".to_string()); + }; + + let mut docker_args = vec!["exec".to_string()]; + + if let Some(parsed_working_dir) = parsed_working_dir { + docker_args.push("-w".to_string()); + docker_args.push(parsed_working_dir); + } + + for (k, v) in env.iter() { + docker_args.push("-e".to_string()); + docker_args.push(format!("{}={}", k, v)); + } + + docker_args.push("-it".to_string()); + docker_args.push(self.connection_options.container_id.to_string()); + + docker_args.append(&mut inner_program); + + Ok(CommandTemplate { + program: "docker".to_string(), + args: docker_args, + // Docker-exec pipes in environment via the "-e" argument + env: Default::default(), + }) + } + + fn build_forward_ports_command( + &self, + _forwards: Vec<(u16, String, u16)>, + ) -> Result { + Err(anyhow::anyhow!("Not currently supported for docker_exec")) + } + + fn connection_options(&self) -> RemoteConnectionOptions { + RemoteConnectionOptions::Docker(self.connection_options.clone()) + } + + fn path_style(&self) -> PathStyle { + self.path_style.unwrap_or(PathStyle::Posix) + } + + fn shell(&self) -> String { + match &self.shell { + Some(shell) => shell.clone(), + None => self.default_system_shell(), + } + } + + fn default_system_shell(&self) -> String { + String::from("/bin/sh") + } +} diff --git a/crates/settings/src/settings_content.rs b/crates/settings/src/settings_content.rs index 230e1ffd48b9cc1d58aba59ea0af2c629e36c8e3..36c8520f9313c48408b37caabe61dd29106cacae 100644 --- a/crates/settings/src/settings_content.rs +++ b/crates/settings/src/settings_content.rs @@ -889,9 +889,19 @@ pub enum ImageFileSizeUnit { pub struct RemoteSettingsContent { pub ssh_connections: Option>, pub wsl_connections: Option>, + pub dev_container_connections: Option>, pub read_ssh_config: Option, } +#[with_fallible_options] +#[derive( + Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, MergeFrom, Hash, +)] +pub struct DevContainerConnection { + pub name: SharedString, + pub container_id: SharedString, +} + #[with_fallible_options] #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, JsonSchema, MergeFrom)] pub struct SshConnection { @@ -901,7 +911,7 @@ pub struct SshConnection { #[serde(default)] pub args: Vec, #[serde(default)] - pub projects: collections::BTreeSet, + pub projects: collections::BTreeSet, /// Name to use for this server in UI. pub nickname: Option, // By default Zed will download the binary to the host directly. @@ -918,14 +928,14 @@ pub struct WslConnection { pub distro_name: SharedString, pub user: Option, #[serde(default)] - pub projects: BTreeSet, + pub projects: BTreeSet, } #[with_fallible_options] #[derive( Clone, Debug, Default, Serialize, PartialEq, Eq, PartialOrd, Ord, Deserialize, JsonSchema, )] -pub struct SshProject { +pub struct RemoteProject { pub paths: Vec, } diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 945b28a02d1e3f7d6e358c2dad0107d7404aa84b..680c455e73ab135f418f199f06415fff79100ea5 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -323,12 +323,18 @@ impl TitleBar { let options = self.project.read(cx).remote_connection_options(cx)?; let host: SharedString = options.display_name().into(); - let (nickname, icon) = match options { - RemoteConnectionOptions::Ssh(options) => { - (options.nickname.map(|nick| nick.into()), IconName::Server) + let (nickname, tooltip_title, icon) = match options { + RemoteConnectionOptions::Ssh(options) => ( + options.nickname.map(|nick| nick.into()), + "Remote Project", + IconName::Server, + ), + RemoteConnectionOptions::Wsl(_) => (None, "Remote Project", IconName::Linux), + RemoteConnectionOptions::Docker(_dev_container_connection) => { + (None, "Dev Container", IconName::Box) } - RemoteConnectionOptions::Wsl(_) => (None, IconName::Linux), }; + let nickname = nickname.unwrap_or_else(|| host.clone()); let (indicator_color, meta) = match self.project.read(cx).remote_connection_state(cx)? { @@ -375,7 +381,7 @@ impl TitleBar { ) .tooltip(move |_window, cx| { Tooltip::with_meta( - "Remote Project", + tooltip_title, Some(&OpenRemote { from_existing_connection: false, create_new_window: false, diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 103e51d548648c18b5b2d724362228948a70930b..f1835caf8dd84e1f729e0415b5711ffa69981d9b 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -20,7 +20,9 @@ use project::debugger::breakpoint_store::{BreakpointState, SourceBreakpoint}; use language::{LanguageName, Toolchain, ToolchainScope}; use project::WorktreeId; -use remote::{RemoteConnectionOptions, SshConnectionOptions, WslConnectionOptions}; +use remote::{ + DockerConnectionOptions, RemoteConnectionOptions, SshConnectionOptions, WslConnectionOptions, +}; use sqlez::{ bindable::{Bind, Column, StaticColumnCount}, statement::Statement, @@ -702,6 +704,10 @@ impl Domain for WorkspaceDb { sql!( DROP TABLE ssh_connections; ), + sql!( + ALTER TABLE remote_connections ADD COLUMN name TEXT; + ALTER TABLE remote_connections ADD COLUMN container_id TEXT; + ), ]; // Allow recovering from bad migration that was initially shipped to nightly @@ -728,9 +734,9 @@ impl WorkspaceDb { pub(crate) fn remote_workspace_for_roots>( &self, worktree_roots: &[P], - ssh_project_id: RemoteConnectionId, + remote_project_id: RemoteConnectionId, ) -> Option { - self.workspace_for_roots_internal(worktree_roots, Some(ssh_project_id)) + self.workspace_for_roots_internal(worktree_roots, Some(remote_project_id)) } pub(crate) fn workspace_for_roots_internal>( @@ -806,9 +812,20 @@ impl WorkspaceDb { order: paths_order, }); + let remote_connection_options = if let Some(remote_connection_id) = remote_connection_id { + self.remote_connection(remote_connection_id) + .context("Get remote connection") + .log_err() + } else { + None + }; + Some(SerializedWorkspace { id: workspace_id, - location: SerializedWorkspaceLocation::Local, + location: match remote_connection_options { + Some(options) => SerializedWorkspaceLocation::Remote(options), + None => SerializedWorkspaceLocation::Local, + }, paths, center_group: self .get_center_pane_group(workspace_id) @@ -1110,10 +1127,12 @@ impl WorkspaceDb { options: RemoteConnectionOptions, ) -> Result { let kind; - let user; + let mut user = None; let mut host = None; let mut port = None; let mut distro = None; + let mut name = None; + let mut container_id = None; match options { RemoteConnectionOptions::Ssh(options) => { kind = RemoteConnectionKind::Ssh; @@ -1126,8 +1145,22 @@ impl WorkspaceDb { distro = Some(options.distro_name); user = options.user; } + RemoteConnectionOptions::Docker(options) => { + kind = RemoteConnectionKind::Docker; + container_id = Some(options.container_id); + name = Some(options.name); + } } - Self::get_or_create_remote_connection_query(this, kind, host, port, user, distro) + Self::get_or_create_remote_connection_query( + this, + kind, + host, + port, + user, + distro, + name, + container_id, + ) } fn get_or_create_remote_connection_query( @@ -1137,6 +1170,8 @@ impl WorkspaceDb { port: Option, user: Option, distro: Option, + name: Option, + container_id: Option, ) -> Result { if let Some(id) = this.select_row_bound(sql!( SELECT id @@ -1146,7 +1181,9 @@ impl WorkspaceDb { host IS ? AND port IS ? AND user IS ? AND - distro IS ? + distro IS ? AND + name IS ? AND + container_id IS ? LIMIT 1 ))?(( kind.serialize(), @@ -1154,6 +1191,8 @@ impl WorkspaceDb { port, user.clone(), distro.clone(), + name.clone(), + container_id.clone(), ))? { Ok(RemoteConnectionId(id)) } else { @@ -1163,10 +1202,20 @@ impl WorkspaceDb { host, port, user, - distro - ) VALUES (?1, ?2, ?3, ?4, ?5) + distro, + name, + container_id + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) RETURNING id - ))?((kind.serialize(), host, port, user, distro))? + ))?(( + kind.serialize(), + host, + port, + user, + distro, + name, + container_id, + ))? .context("failed to insert remote project")?; Ok(RemoteConnectionId(id)) } @@ -1249,15 +1298,23 @@ impl WorkspaceDb { fn remote_connections(&self) -> Result> { Ok(self.select(sql!( SELECT - id, kind, host, port, user, distro + id, kind, host, port, user, distro, container_id, name FROM remote_connections ))?()? .into_iter() - .filter_map(|(id, kind, host, port, user, distro)| { + .filter_map(|(id, kind, host, port, user, distro, container_id, name)| { Some(( RemoteConnectionId(id), - Self::remote_connection_from_row(kind, host, port, user, distro)?, + Self::remote_connection_from_row( + kind, + host, + port, + user, + distro, + container_id, + name, + )?, )) }) .collect()) @@ -1267,13 +1324,13 @@ impl WorkspaceDb { &self, id: RemoteConnectionId, ) -> Result { - let (kind, host, port, user, distro) = self.select_row_bound(sql!( - SELECT kind, host, port, user, distro + let (kind, host, port, user, distro, container_id, name) = self.select_row_bound(sql!( + SELECT kind, host, port, user, distro, container_id, name FROM remote_connections WHERE id = ? ))?(id.0)? .context("no such remote connection")?; - Self::remote_connection_from_row(kind, host, port, user, distro) + Self::remote_connection_from_row(kind, host, port, user, distro, container_id, name) .context("invalid remote_connection row") } @@ -1283,6 +1340,8 @@ impl WorkspaceDb { port: Option, user: Option, distro: Option, + container_id: Option, + name: Option, ) -> Option { match RemoteConnectionKind::deserialize(&kind)? { RemoteConnectionKind::Wsl => Some(RemoteConnectionOptions::Wsl(WslConnectionOptions { @@ -1295,6 +1354,13 @@ impl WorkspaceDb { username: user, ..Default::default() })), + RemoteConnectionKind::Docker => { + Some(RemoteConnectionOptions::Docker(DockerConnectionOptions { + container_id: container_id?, + name: name?, + upload_binary_over_docker_exec: false, + })) + } } } diff --git a/crates/workspace/src/persistence/model.rs b/crates/workspace/src/persistence/model.rs index a37b2ebbe93efb23cad6a98f127ba1f8800a3eb3..08a3adf9ebd7fa49a5f8fb86eec65c66deb00421 100644 --- a/crates/workspace/src/persistence/model.rs +++ b/crates/workspace/src/persistence/model.rs @@ -32,6 +32,7 @@ pub(crate) struct RemoteConnectionId(pub u64); pub(crate) enum RemoteConnectionKind { Ssh, Wsl, + Docker, } #[derive(Debug, PartialEq, Clone)] @@ -75,6 +76,7 @@ impl RemoteConnectionKind { match self { RemoteConnectionKind::Ssh => "ssh", RemoteConnectionKind::Wsl => "wsl", + RemoteConnectionKind::Docker => "docker", } } @@ -82,6 +84,7 @@ impl RemoteConnectionKind { match text { "ssh" => Some(Self::Ssh), "wsl" => Some(Self::Wsl), + "docker" => Some(Self::Docker), _ => None, } } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index cc3ba7577ae6a0d8af889bcde174a00f185dd502..c445ed7822428ebc140a1685c619526d0a2b0ac5 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -7780,7 +7780,7 @@ pub fn open_remote_project_with_new_connection( ) -> Task>>>> { cx.spawn(async move |cx| { let (workspace_id, serialized_workspace) = - serialize_remote_project(remote_connection.connection_options(), paths.clone(), cx) + deserialize_remote_project(remote_connection.connection_options(), paths.clone(), cx) .await?; let session = match cx @@ -7834,7 +7834,7 @@ pub fn open_remote_project_with_existing_connection( ) -> Task>>>> { cx.spawn(async move |cx| { let (workspace_id, serialized_workspace) = - serialize_remote_project(connection_options.clone(), paths.clone(), cx).await?; + deserialize_remote_project(connection_options.clone(), paths.clone(), cx).await?; open_remote_project_inner( project, @@ -7936,7 +7936,7 @@ async fn open_remote_project_inner( Ok(items.into_iter().map(|item| item?.ok()).collect()) } -fn serialize_remote_project( +fn deserialize_remote_project( connection_options: RemoteConnectionOptions, paths: Vec, cx: &AsyncApp, diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index d4d28433d4c76dcab3df627789df82e99854fbc1..a89e943e021e79058953de46bca57713f51598bc 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -428,6 +428,12 @@ pub struct OpenRemote { pub create_new_window: bool, } +/// Opens the dev container connection modal. +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)] +#[action(namespace = projects)] +#[serde(deny_unknown_fields)] +pub struct OpenDevContainer; + /// Where to spawn the task in the UI. #[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")]