Detailed changes
@@ -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",
]
@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M13.3996 5.59852C13.3994 5.3881 13.3439 5.18144 13.2386 4.99926C13.1333 4.81709 12.9819 4.66581 12.7997 4.56059L8.59996 2.16076C8.41755 2.05544 8.21063 2 8 2C7.78937 2 7.58246 2.05544 7.40004 2.16076L3.20033 4.56059C3.0181 4.66581 2.86674 4.81709 2.76144 4.99926C2.65613 5.18144 2.60059 5.3881 2.60037 5.59852V10.3982C2.60059 10.6086 2.65613 10.8153 2.76144 10.9975C2.86674 11.1796 3.0181 11.3309 3.20033 11.4361L7.40004 13.836C7.58246 13.9413 7.78937 13.9967 8 13.9967C8.21063 13.9967 8.41755 13.9413 8.59996 13.836L12.7997 11.4361C12.9819 11.3309 13.1333 11.1796 13.2386 10.9975C13.3439 10.8153 13.3994 10.6086 13.3996 10.3982V5.59852Z" stroke="white" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M2.78033 4.99857L7.99998 7.99836L13.2196 4.99857" stroke="white" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8 13.9979V7.99829" stroke="white" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -49,6 +49,7 @@ pub enum IconName {
BoltOutlined,
Book,
BookCopy,
+ Box,
CaseSensitive,
Chat,
Check,
@@ -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?;
}
@@ -206,14 +206,14 @@ impl NodeRuntime {
pub async fn run_npm_subcommand(
&self,
- directory: &Path,
+ directory: Option<&Path>,
subcommand: &str,
args: &[&str],
) -> Result<Output> {
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?;
@@ -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<PathBuf> = 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"
@@ -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
@@ -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<String>,
+}
+
+#[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<PathBuf, DevContainerError> {
+ 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<Path>,
+) -> Result<DevContainerUp, DevContainerError> {
+ 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::<DevContainerUp>(&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<Path>,
+) -> Result<DevContainerConfigurationOutput, DevContainerError> {
+ 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::<DevContainerConfigurationOutput>(&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<Path>,
+ remote_workspace_folder: String,
+ container_id: String,
+) -> Result<String, DevContainerError> {
+ 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<Arc<Path>> {
+ let Some(workspace) = cx.window_handle().downcast::<Workspace>() 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");
+ }
+}
@@ -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<Project>,
+ window: &mut Window,
+ cx: &mut Context<Workspace>,
+) {
+ 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::<DevContainerSuggestionNotification>(
+ 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())
+ });
+ }
+ })
+ })
+ });
+ });
+}
@@ -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::<Workspace>();
+
+ 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<Workspace>| {
+ 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()
@@ -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<Connection> for RemoteConnectionOptions {
@@ -92,6 +93,13 @@ impl From<Connection> 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<SharedString>,
is_wsl: bool,
+ is_devcontainer: bool,
status_message: Option<SharedString>,
prompt: Option<(Entity<Markdown>, oneshot::Sender<EncryptedPassword>)>,
cancellation: Option<oneshot::Sender<()>>,
@@ -148,6 +157,7 @@ impl RemoteConnectionPrompt {
connection_string: String,
nickname: Option<String>,
is_wsl: bool,
+ is_devcontainer: bool,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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>,
) -> 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<PathBuf>,
pub(crate) nickname: Option<SharedString>,
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"],
@@ -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<RemoteServerProjects>) -> 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<Picker<crate::wsl_picker::WslPickerDelegate>>,
@@ -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<WslServerIndex> 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<RemoteEntry>,
}
@@ -448,6 +491,7 @@ impl DefaultState {
fn new(ssh_config_servers: &BTreeSet<SharedString>, 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<ProjectPicker>),
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<dyn Fs>,
+ window: &mut Window,
+ workspace: WeakEntity<Workspace>,
+ cx: &mut Context<Self>,
+ ) -> 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>) {
+ 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<Self>,
) -> 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<Self>,
) -> impl IntoElement {
@@ -1372,7 +1454,7 @@ impl RemoteServerProjects {
fn delete_remote_project(
&mut self,
server: ServerIndex,
- project: &SshProject,
+ project: &RemoteProject,
cx: &mut Context<Self>,
) {
match server {
@@ -1388,7 +1470,7 @@ impl RemoteServerProjects {
fn delete_ssh_project(
&mut self,
server: SshServerIndex,
- project: &SshProject,
+ project: &RemoteProject,
cx: &mut Context<Self>,
) {
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<Self>,
) {
let project = project.clone();
@@ -1451,6 +1533,342 @@ impl RemoteServerProjects {
});
}
+ fn edit_in_dev_container_json(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ 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<Self>) {
+ 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::<Workspace>();
+
+ 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<Self>,
+ ) -> 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(),
@@ -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;
@@ -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<dyn RemoteConnection>)
}
+ RemoteConnectionOptions::Docker(opts) => {
+ DockerExecConnection::new(opts, delegate, cx)
+ .await
+ .map(|connection| Arc::new(connection) as Arc<dyn RemoteConnection>)
+ }
};
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(),
}
}
}
@@ -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<Envelope>,
mut outgoing_rx: UnboundedReceiver<Envelope>,
mut connection_activity_tx: Sender<()>,
cx: &AsyncApp,
) -> Task<Result<i32>> {
- 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),
@@ -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<Option<u32>>,
+ remote_dir_for_server: String,
+ remote_binary_relpath: Option<Arc<RelPath>>,
+ connection_options: DockerConnectionOptions,
+ remote_platform: Option<RemotePlatform>,
+ path_style: Option<PathStyle>,
+ shell: Option<String>,
+}
+
+impl DockerExecConnection {
+ pub async fn new(
+ connection_options: DockerConnectionOptions,
+ delegate: Arc<dyn RemoteClientDelegate>,
+ cx: &mut AsyncApp,
+ ) -> Result<Self> {
+ 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<RemotePlatform> {
+ 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<dyn RemoteClientDelegate>,
+ release_channel: ReleaseChannel,
+ version: SemanticVersion,
+ remote_dir_for_server: &str,
+ commit: Option<AppCommitSha>,
+ cx: &mut AsyncApp,
+ ) -> Result<Arc<RelPath>> {
+ 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<String> {
+ 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<dyn RemoteClientDelegate>,
+ 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<dyn RemoteClientDelegate>,
+ 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<str>],
+ ) -> Result<String> {
+ 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<String, String>,
+ program_args: &[impl AsRef<str>],
+ ) -> Result<String> {
+ 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<dyn RemoteClientDelegate>,
+ 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<Envelope>,
+ outgoing_rx: UnboundedReceiver<Envelope>,
+ connection_activity_tx: Sender<()>,
+ delegate: Arc<dyn RemoteClientDelegate>,
+ cx: &mut AsyncApp,
+ ) -> Task<Result<i32>> {
+ // 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<Result<()>> {
+ 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<String>,
+ args: &[String],
+ env: &HashMap<String, String>,
+ working_dir: Option<String>,
+ _port_forward: Option<(u16, String, u16)>,
+ ) -> Result<CommandTemplate> {
+ 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<CommandTemplate> {
+ 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")
+ }
+}
@@ -889,9 +889,19 @@ pub enum ImageFileSizeUnit {
pub struct RemoteSettingsContent {
pub ssh_connections: Option<Vec<SshConnection>>,
pub wsl_connections: Option<Vec<WslConnection>>,
+ pub dev_container_connections: Option<Vec<DevContainerConnection>>,
pub read_ssh_config: Option<bool>,
}
+#[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<String>,
#[serde(default)]
- pub projects: collections::BTreeSet<SshProject>,
+ pub projects: collections::BTreeSet<RemoteProject>,
/// Name to use for this server in UI.
pub nickname: Option<String>,
// 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<String>,
#[serde(default)]
- pub projects: BTreeSet<SshProject>,
+ pub projects: BTreeSet<RemoteProject>,
}
#[with_fallible_options]
#[derive(
Clone, Debug, Default, Serialize, PartialEq, Eq, PartialOrd, Ord, Deserialize, JsonSchema,
)]
-pub struct SshProject {
+pub struct RemoteProject {
pub paths: Vec<String>,
}
@@ -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,
@@ -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<P: AsRef<Path>>(
&self,
worktree_roots: &[P],
- ssh_project_id: RemoteConnectionId,
+ remote_project_id: RemoteConnectionId,
) -> Option<SerializedWorkspace> {
- 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<P: AsRef<Path>>(
@@ -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<RemoteConnectionId> {
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<u16>,
user: Option<String>,
distro: Option<String>,
+ name: Option<String>,
+ container_id: Option<String>,
) -> Result<RemoteConnectionId> {
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<HashMap<RemoteConnectionId, RemoteConnectionOptions>> {
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<RemoteConnectionOptions> {
- 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<u16>,
user: Option<String>,
distro: Option<String>,
+ container_id: Option<String>,
+ name: Option<String>,
) -> Option<RemoteConnectionOptions> {
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,
+ }))
+ }
}
}
@@ -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,
}
}
@@ -7780,7 +7780,7 @@ pub fn open_remote_project_with_new_connection(
) -> Task<Result<Vec<Option<Box<dyn ItemHandle>>>>> {
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<Result<Vec<Option<Box<dyn ItemHandle>>>>> {
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<PathBuf>,
cx: &AsyncApp,
@@ -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")]