build: Decouple git_ui from recent_projects (#48062)

Piotr Osiewicz created

- **git_ui: Decouple git_ui from the recent_projects crate**
- **Move git_ui closer to editor**

Release Notes:

- N/A

Change summary

Cargo.lock                                        |  31 
Cargo.toml                                        |   2 
crates/editor/src/editor.rs                       |   6 
crates/git_ui/Cargo.toml                          |   6 
crates/git_ui/src/commit_modal.rs                 |   4 
crates/git_ui/src/git_panel.rs                    |  61 +
crates/git_ui/src/worktree_picker.rs              |   2 
crates/panel/Cargo.toml                           |   3 
crates/panel/src/panel.rs                         |  63 -
crates/recent_projects/Cargo.toml                 |   4 
crates/recent_projects/src/recent_projects.rs     |   3 
crates/recent_projects/src/remote_connections.rs  | 542 ----------------
crates/remote_connection/Cargo.toml               |  34 +
crates/remote_connection/LICENSE-GPL              |   1 
crates/remote_connection/src/remote_connection.rs | 522 ++++++++++++++++
crates/ui_input/src/ui_input.rs                   |   1 
16 files changed, 670 insertions(+), 615 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -7294,8 +7294,8 @@ dependencies = [
  "project",
  "prompt_store",
  "rand 0.9.2",
- "recent_projects",
  "remote",
+ "remote_connection",
  "schemars",
  "serde",
  "serde_json",
@@ -11456,10 +11456,7 @@ dependencies = [
 name = "panel"
 version = "0.1.0"
 dependencies = [
- "editor",
  "gpui",
- "settings",
- "theme",
  "ui",
  "workspace",
 ]
@@ -13495,7 +13492,6 @@ version = "0.1.0"
 dependencies = [
  "anyhow",
  "askpass",
- "auto_update",
  "dap",
  "db",
  "dev_container",
@@ -13510,7 +13506,6 @@ dependencies = [
  "indoc",
  "language",
  "log",
- "markdown",
  "menu",
  "node_runtime",
  "open_path_prompt",
@@ -13520,6 +13515,7 @@ dependencies = [
  "project",
  "release_channel",
  "remote",
+ "remote_connection",
  "remote_server",
  "semver",
  "serde",
@@ -13528,7 +13524,6 @@ dependencies = [
  "smol",
  "task",
  "telemetry",
- "theme",
  "ui",
  "util",
  "windows-registry 0.6.1",
@@ -13708,6 +13703,28 @@ dependencies = [
  "which 6.0.3",
 ]
 
+[[package]]
+name = "remote_connection"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "askpass",
+ "auto_update",
+ "futures 0.3.31",
+ "gpui",
+ "log",
+ "markdown",
+ "menu",
+ "release_channel",
+ "remote",
+ "semver",
+ "settings",
+ "theme",
+ "ui",
+ "ui_input",
+ "workspace",
+]
+
 [[package]]
 name = "remote_server"
 version = "0.1.0"

Cargo.toml 🔗

@@ -144,6 +144,7 @@ members = [
     "crates/release_channel",
     "crates/scheduler",
     "crates/remote",
+    "crates/remote_connection",
     "crates/remote_server",
     "crates/repl",
     "crates/reqwest_client",
@@ -383,6 +384,7 @@ recent_projects = { path = "crates/recent_projects" }
 refineable = { path = "crates/refineable" }
 release_channel = { path = "crates/release_channel" }
 remote = { path = "crates/remote" }
+remote_connection = { path = "crates/remote_connection" }
 remote_server = { path = "crates/remote_server" }
 repl = { path = "crates/repl" }
 reqwest_client = { path = "crates/reqwest_client" }

crates/editor/src/editor.rs 🔗

@@ -27879,6 +27879,12 @@ impl ui_input::ErasedEditor for ErasedEditorImpl {
             (callback)(event, window, cx);
         })
     }
+
+    fn set_masked(&self, masked: bool, _window: &mut Window, cx: &mut App) {
+        self.0.update(cx, |editor, cx| {
+            editor.set_masked(masked, cx);
+        });
+    }
 }
 impl<T> Default for InvalidationStack<T> {
     fn default() -> Self {

crates/git_ui/Cargo.toml 🔗

@@ -13,7 +13,7 @@ name = "git_ui"
 path = "src/git_ui.rs"
 
 [features]
-test-support = ["multi_buffer/test-support", "recent_projects/test-support"]
+test-support = ["multi_buffer/test-support", "remote_connection/test-support"]
 
 [dependencies]
 agent_settings.workspace = true
@@ -45,7 +45,7 @@ panel.workspace = true
 picker.workspace = true
 project.workspace = true
 prompt_store.workspace = true
-recent_projects.workspace = true
+remote_connection.workspace = true
 remote.workspace = true
 schemars.workspace = true
 serde.workspace = true
@@ -82,7 +82,7 @@ settings = { workspace = true, features = ["test-support"] }
 unindent.workspace = true
 workspace = { workspace = true, features = ["test-support"] }
 zlog.workspace = true
-recent_projects = { workspace = true, features = ["test-support"] }
+remote_connection = { workspace = true, features = ["test-support"] }
 
 [package.metadata.cargo-machete]
 ignored = ["tracing"]

crates/git_ui/src/commit_modal.rs 🔗

@@ -1,8 +1,8 @@
 use crate::branch_picker::{self, BranchList};
-use crate::git_panel::{GitPanel, commit_message_editor};
+use crate::git_panel::{GitPanel, commit_message_editor, panel_editor_style};
 use git::repository::CommitOptions;
 use git::{Amend, Commit, GenerateCommitMessage, Signoff};
-use panel::{panel_button, panel_editor_style};
+use panel::panel_button;
 use project::DisableAiSettings;
 use settings::Settings;
 use ui::{

crates/git_ui/src/git_panel.rs 🔗

@@ -15,11 +15,11 @@ use askpass::AskPassDelegate;
 use cloud_llm_client::CompletionIntent;
 use collections::{BTreeMap, HashMap, HashSet};
 use db::kvp::KEY_VALUE_STORE;
-use editor::RewrapOptions;
 use editor::{
     Direction, Editor, EditorElement, EditorMode, MultiBuffer, MultiBufferOffset,
     actions::ExpandAllDiffHunks,
 };
+use editor::{EditorStyle, RewrapOptions};
 use futures::StreamExt as _;
 use git::commit::ParsedCommitMessage;
 use git::repository::{
@@ -37,8 +37,8 @@ use git::{
 use gpui::{
     Action, AsyncApp, AsyncWindowContext, Bounds, ClickEvent, Corner, DismissEvent, Empty, Entity,
     EventEmitter, FocusHandle, Focusable, KeyContext, MouseButton, MouseDownEvent, Point,
-    PromptLevel, ScrollStrategy, Subscription, Task, UniformListScrollHandle, WeakEntity, actions,
-    anchored, deferred, point, size, uniform_list,
+    PromptLevel, ScrollStrategy, Subscription, Task, TextStyle, UniformListScrollHandle,
+    WeakEntity, actions, anchored, deferred, point, size, uniform_list,
 };
 use itertools::Itertools;
 use language::{Buffer, File};
@@ -48,10 +48,7 @@ use language_model::{
 use menu;
 use multi_buffer::ExcerptInfo;
 use notifications::status_toast::{StatusToast, ToastIcon};
-use panel::{
-    PanelHeader, panel_button, panel_editor_container, panel_editor_style, panel_filled_button,
-    panel_icon_button,
-};
+use panel::{PanelHeader, panel_button, panel_filled_button, panel_icon_button};
 use project::{
     Fs, Project, ProjectPath,
     git_store::{GitStoreEvent, Repository, RepositoryEvent, RepositoryId, pending_op},
@@ -65,6 +62,7 @@ use std::ops::Range;
 use std::path::Path;
 use std::{sync::Arc, time::Duration, usize};
 use strum::{IntoEnumIterator, VariantNames};
+use theme::ThemeSettings;
 use time::OffsetDateTime;
 use ui::{
     ButtonLike, Checkbox, CommonAnimationExt, ContextMenu, ElevationIndex, IndentGuideColors,
@@ -5600,6 +5598,55 @@ impl Panel for GitPanel {
 
 impl PanelHeader for GitPanel {}
 
+pub fn panel_editor_container(_window: &mut Window, cx: &mut App) -> Div {
+    v_flex()
+        .size_full()
+        .gap(px(8.))
+        .p_2()
+        .bg(cx.theme().colors().editor_background)
+}
+
+pub(crate) fn panel_editor_style(monospace: bool, window: &Window, cx: &App) -> EditorStyle {
+    let settings = ThemeSettings::get_global(cx);
+
+    let font_size = TextSize::Small.rems(cx).to_pixels(window.rem_size());
+
+    let (font_family, font_fallbacks, font_features, font_weight, line_height) = if monospace {
+        (
+            settings.buffer_font.family.clone(),
+            settings.buffer_font.fallbacks.clone(),
+            settings.buffer_font.features.clone(),
+            settings.buffer_font.weight,
+            font_size * settings.buffer_line_height.value(),
+        )
+    } else {
+        (
+            settings.ui_font.family.clone(),
+            settings.ui_font.fallbacks.clone(),
+            settings.ui_font.features.clone(),
+            settings.ui_font.weight,
+            window.line_height(),
+        )
+    };
+
+    EditorStyle {
+        background: cx.theme().colors().editor_background,
+        local_player: cx.theme().players().local(),
+        text: TextStyle {
+            color: cx.theme().colors().text,
+            font_family,
+            font_fallbacks,
+            font_features,
+            font_size: TextSize::Small.rems(cx).into(),
+            font_weight,
+            line_height: line_height.into(),
+            ..Default::default()
+        },
+        syntax: cx.theme().syntax().clone(),
+        ..Default::default()
+    }
+}
+
 struct GitPanelMessageTooltip {
     commit_tooltip: Option<Entity<CommitTooltip>>,
 }

crates/git_ui/src/worktree_picker.rs 🔗

@@ -15,8 +15,8 @@ use project::{
     git_store::Repository,
     trusted_worktrees::{PathTrust, TrustedWorktrees},
 };
-use recent_projects::{RemoteConnectionModal, connect};
 use remote::{RemoteConnectionOptions, remote_client::ConnectionIdentifier};
+use remote_connection::{RemoteConnectionModal, connect};
 use std::{path::PathBuf, sync::Arc};
 use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, prelude::*};
 use util::ResultExt;

crates/panel/Cargo.toml 🔗

@@ -12,9 +12,6 @@ workspace = true
 path = "src/panel.rs"
 
 [dependencies]
-editor.workspace = true
 gpui.workspace = true
-settings.workspace = true
-theme.workspace = true
 ui.workspace = true
 workspace.workspace = true

crates/panel/src/panel.rs 🔗

@@ -1,8 +1,5 @@
 //! # panel
-use editor::{Editor, EditorElement, EditorStyle};
-use gpui::{Entity, TextStyle, actions};
-use settings::Settings;
-use theme::ThemeSettings;
+use gpui::actions;
 use ui::{Tab, prelude::*};
 
 actions!(
@@ -76,61 +73,3 @@ pub fn panel_icon_button(id: impl Into<SharedString>, icon: IconName) -> ui::Ico
 pub fn panel_filled_icon_button(id: impl Into<SharedString>, icon: IconName) -> ui::IconButton {
     panel_icon_button(id, icon).style(ui::ButtonStyle::Filled)
 }
-
-pub fn panel_editor_container(_window: &mut Window, cx: &mut App) -> Div {
-    v_flex()
-        .size_full()
-        .gap(px(8.))
-        .p_2()
-        .bg(cx.theme().colors().editor_background)
-}
-
-pub fn panel_editor_style(monospace: bool, window: &Window, cx: &App) -> EditorStyle {
-    let settings = ThemeSettings::get_global(cx);
-
-    let font_size = TextSize::Small.rems(cx).to_pixels(window.rem_size());
-
-    let (font_family, font_fallbacks, font_features, font_weight, line_height) = if monospace {
-        (
-            settings.buffer_font.family.clone(),
-            settings.buffer_font.fallbacks.clone(),
-            settings.buffer_font.features.clone(),
-            settings.buffer_font.weight,
-            font_size * settings.buffer_line_height.value(),
-        )
-    } else {
-        (
-            settings.ui_font.family.clone(),
-            settings.ui_font.fallbacks.clone(),
-            settings.ui_font.features.clone(),
-            settings.ui_font.weight,
-            window.line_height(),
-        )
-    };
-
-    EditorStyle {
-        background: cx.theme().colors().editor_background,
-        local_player: cx.theme().players().local(),
-        text: TextStyle {
-            color: cx.theme().colors().text,
-            font_family,
-            font_fallbacks,
-            font_features,
-            font_size: TextSize::Small.rems(cx).into(),
-            font_weight,
-            line_height: line_height.into(),
-            ..Default::default()
-        },
-        syntax: cx.theme().syntax().clone(),
-        ..Default::default()
-    }
-}
-
-pub fn panel_editor_element(
-    editor: &Entity<Editor>,
-    monospace: bool,
-    window: &mut Window,
-    cx: &mut App,
-) -> EditorElement {
-    EditorElement::new(editor, panel_editor_style(monospace, window, cx))
-}

crates/recent_projects/Cargo.toml 🔗

@@ -19,7 +19,6 @@ test-support = ["remote/test-support", "project/test-support", "workspace/test-s
 [dependencies]
 anyhow.workspace = true
 askpass.workspace = true
-auto_update.workspace = true
 db.workspace = true
 dev_container.workspace = true
 editor.workspace = true
@@ -29,7 +28,6 @@ fuzzy.workspace = true
 gpui.workspace = true
 language.workspace = true
 log.workspace = true
-markdown.workspace = true
 menu.workspace = true
 node_runtime.workspace = true
 open_path_prompt.workspace = true
@@ -39,6 +37,7 @@ picker.workspace = true
 project.workspace = true
 release_channel.workspace = true
 remote.workspace = true
+remote_connection.workspace = true
 semver.workspace = true
 serde.workspace = true
 serde_json.workspace = true
@@ -46,7 +45,6 @@ settings.workspace = true
 smol.workspace = true
 task.workspace = true
 telemetry.workspace = true
-theme.workspace = true
 ui.workspace = true
 util.workspace = true
 workspace.workspace = true

crates/recent_projects/src/recent_projects.rs 🔗

@@ -11,7 +11,8 @@ mod wsl_picker;
 
 use dev_container::start_dev_container;
 use remote::RemoteConnectionOptions;
-pub use remote_connections::{RemoteConnectionModal, connect, open_remote_project};
+pub use remote_connection::{RemoteConnectionModal, connect};
+pub use remote_connections::open_remote_project;
 
 use disconnected_overlay::DisconnectedOverlay;
 use fuzzy::{StringMatch, StringMatchCandidate};

crates/recent_projects/src/remote_connections.rs 🔗

@@ -5,33 +5,26 @@ use std::{
 
 use anyhow::{Context as _, Result};
 use askpass::EncryptedPassword;
-use auto_update::AutoUpdater;
 use editor::Editor;
 use extension_host::ExtensionStore;
 use futures::{FutureExt as _, channel::oneshot, select};
-use gpui::{
-    AnyWindowHandle, App, AsyncApp, DismissEvent, Entity, EventEmitter, Focusable, FontFeatures,
-    ParentElement as _, PromptLevel, Render, SharedString, Task, TextStyleRefinement, WeakEntity,
-};
+use gpui::{AppContext, AsyncApp, PromptLevel};
 
-use language::{CursorShape, Point};
-use markdown::{Markdown, MarkdownElement, MarkdownStyle};
+use language::Point;
 use project::trusted_worktrees;
-use release_channel::ReleaseChannel;
 use remote::{
-    ConnectionIdentifier, DockerConnectionOptions, Interactive, RemoteClient, RemoteConnection,
-    RemoteConnectionOptions, RemotePlatform, SshConnectionOptions,
+    DockerConnectionOptions, Interactive, RemoteConnection, RemoteConnectionOptions,
+    SshConnectionOptions,
 };
-use semver::Version;
 pub use settings::SshConnection;
 use settings::{DevContainerConnection, ExtendingVec, RegisterSetting, Settings, WslConnection};
-use theme::ThemeSettings;
-use ui::{
-    ActiveTheme, Color, CommonAnimationExt, Context, InteractiveElement, IntoElement, KeyBinding,
-    LabelCommon, ListItem, Styled, Window, prelude::*,
-};
 use util::paths::PathWithPosition;
-use workspace::{AppState, ModalView, Workspace};
+use workspace::{AppState, Workspace};
+
+pub use remote_connection::{
+    RemoteClientDelegate, RemoteConnectionModal, RemoteConnectionPrompt, SshConnectionHeader,
+    connect,
+};
 
 #[derive(RegisterSetting)]
 pub struct RemoteSettings {
@@ -129,505 +122,6 @@ impl Settings for RemoteSettings {
     }
 }
 
-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<()>>,
-    editor: Entity<Editor>,
-}
-
-impl Drop for RemoteConnectionPrompt {
-    fn drop(&mut self) {
-        if let Some(cancel) = self.cancellation.take() {
-            log::debug!("cancelling remote connection");
-            cancel.send(()).ok();
-        }
-    }
-}
-
-pub struct RemoteConnectionModal {
-    pub prompt: Entity<RemoteConnectionPrompt>,
-    paths: Vec<PathBuf>,
-    finished: bool,
-}
-
-impl RemoteConnectionPrompt {
-    pub(crate) fn new(
-        connection_string: String,
-        nickname: Option<String>,
-        is_wsl: bool,
-        is_devcontainer: bool,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) -> Self {
-        Self {
-            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,
-            prompt: None,
-        }
-    }
-
-    pub fn set_cancellation_tx(&mut self, tx: oneshot::Sender<()>) {
-        self.cancellation = Some(tx);
-    }
-
-    fn set_prompt(
-        &mut self,
-        prompt: String,
-        tx: oneshot::Sender<EncryptedPassword>,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) {
-        let theme = ThemeSettings::get_global(cx);
-
-        let refinement = TextStyleRefinement {
-            font_family: Some(theme.buffer_font.family.clone()),
-            font_features: Some(FontFeatures::disable_ligatures()),
-            font_size: Some(theme.buffer_font_size(cx).into()),
-            color: Some(cx.theme().colors().editor_foreground),
-            background_color: Some(gpui::transparent_black()),
-            ..Default::default()
-        };
-
-        self.editor.update(cx, |editor, cx| {
-            if prompt.contains("yes/no") {
-                editor.set_masked(false, cx);
-            } else {
-                editor.set_masked(true, cx);
-            }
-            editor.set_text_style_refinement(refinement);
-            editor.set_cursor_shape(CursorShape::Block, cx);
-        });
-
-        let markdown = cx.new(|cx| Markdown::new_text(prompt.into(), cx));
-        self.prompt = Some((markdown, tx));
-        self.status_message.take();
-        window.focus(&self.editor.focus_handle(cx), cx);
-        cx.notify();
-    }
-
-    pub fn set_status(&mut self, status: Option<String>, cx: &mut Context<Self>) {
-        self.status_message = status.map(|s| s.into());
-        cx.notify();
-    }
-
-    pub fn confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
-        if let Some((_, tx)) = self.prompt.take() {
-            self.status_message = Some("Connecting".into());
-
-            self.editor.update(cx, |editor, cx| {
-                let pw = editor.text(cx);
-                if let Ok(secure) = EncryptedPassword::try_from(pw.as_ref()) {
-                    tx.send(secure).ok();
-                }
-                editor.clear(window, cx);
-            });
-        }
-    }
-}
-
-impl Render for RemoteConnectionPrompt {
-    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        let theme = ThemeSettings::get_global(cx);
-
-        let mut text_style = window.text_style();
-        let refinement = TextStyleRefinement {
-            font_family: Some(theme.buffer_font.family.clone()),
-            font_features: Some(FontFeatures::disable_ligatures()),
-            font_size: Some(theme.buffer_font_size(cx).into()),
-            color: Some(cx.theme().colors().editor_foreground),
-            background_color: Some(gpui::transparent_black()),
-            ..Default::default()
-        };
-
-        text_style.refine(&refinement);
-        let markdown_style = MarkdownStyle {
-            base_text_style: text_style,
-            selection_background_color: cx.theme().colors().element_selection_background,
-            ..Default::default()
-        };
-
-        v_flex()
-            .key_context("PasswordPrompt")
-            .p_2()
-            .size_full()
-            .text_buffer(cx)
-            .when_some(self.status_message.clone(), |el, status_message| {
-                el.child(
-                    h_flex()
-                        .gap_2()
-                        .child(
-                            Icon::new(IconName::ArrowCircle)
-                                .color(Color::Muted)
-                                .with_rotate_animation(2),
-                        )
-                        .child(
-                            div()
-                                .text_ellipsis()
-                                .overflow_x_hidden()
-                                .child(format!("{}…", status_message)),
-                        ),
-                )
-            })
-            .when_some(self.prompt.as_ref(), |el, prompt| {
-                el.child(
-                    div()
-                        .size_full()
-                        .overflow_hidden()
-                        .child(MarkdownElement::new(prompt.0.clone(), markdown_style))
-                        .child(self.editor.clone()),
-                )
-                .when(window.capslock().on, |el| {
-                    el.child(Label::new("⚠️ ⇪ is on"))
-                })
-            })
-    }
-}
-
-impl RemoteConnectionModal {
-    pub fn new(
-        connection_options: &RemoteConnectionOptions,
-        paths: Vec<PathBuf>,
-        window: &mut Window,
-        cx: &mut Context<Self>,
-    ) -> Self {
-        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::Docker(options) => (options.name.clone(), None, false, true),
-            #[cfg(any(test, feature = "test-support"))]
-            RemoteConnectionOptions::Mock(options) => {
-                (format!("mock-{}", options.id), None, false, false)
-            }
-        };
-        Self {
-            prompt: cx.new(|cx| {
-                RemoteConnectionPrompt::new(
-                    connection_string,
-                    nickname,
-                    is_wsl,
-                    is_devcontainer,
-                    window,
-                    cx,
-                )
-            }),
-            finished: false,
-            paths,
-        }
-    }
-
-    fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
-        self.prompt
-            .update(cx, |prompt, cx| prompt.confirm(window, cx))
-    }
-
-    pub fn finished(&mut self, cx: &mut Context<Self>) {
-        self.finished = true;
-        cx.emit(DismissEvent);
-    }
-
-    fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
-        if let Some(tx) = self
-            .prompt
-            .update(cx, |prompt, _cx| prompt.cancellation.take())
-        {
-            log::debug!("cancelling remote connection");
-            tx.send(()).ok();
-        }
-        self.finished(cx);
-    }
-}
-
-pub(crate) struct SshConnectionHeader {
-    pub(crate) connection_string: SharedString,
-    pub(crate) paths: Vec<PathBuf>,
-    pub(crate) nickname: Option<SharedString>,
-    pub(crate) is_wsl: bool,
-    pub(crate) is_devcontainer: bool,
-}
-
-impl RenderOnce for SshConnectionHeader {
-    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
-        let theme = cx.theme();
-
-        let mut header_color = theme.colors().text;
-        header_color.fade_out(0.96);
-
-        let (main_label, meta_label) = if let Some(nickname) = self.nickname {
-            (nickname, Some(format!("({})", self.connection_string)))
-        } else {
-            (self.connection_string, None)
-        };
-
-        let icon = if self.is_wsl {
-            IconName::Linux
-        } else if self.is_devcontainer {
-            IconName::Box
-        } else {
-            IconName::Server
-        };
-
-        h_flex()
-            .px(DynamicSpacing::Base12.rems(cx))
-            .pt(DynamicSpacing::Base08.rems(cx))
-            .pb(DynamicSpacing::Base04.rems(cx))
-            .rounded_t_sm()
-            .w_full()
-            .gap_1p5()
-            .child(Icon::new(icon).size(IconSize::Small))
-            .child(
-                h_flex()
-                    .gap_1()
-                    .overflow_x_hidden()
-                    .child(
-                        div()
-                            .max_w_96()
-                            .overflow_x_hidden()
-                            .text_ellipsis()
-                            .child(Headline::new(main_label).size(HeadlineSize::XSmall)),
-                    )
-                    .children(
-                        meta_label.map(|label| {
-                            Label::new(label).color(Color::Muted).size(LabelSize::Small)
-                        }),
-                    )
-                    .child(div().overflow_x_hidden().text_ellipsis().children(
-                        self.paths.into_iter().map(|path| {
-                            Label::new(path.to_string_lossy().into_owned())
-                                .size(LabelSize::Small)
-                                .color(Color::Muted)
-                        }),
-                    )),
-            )
-    }
-}
-
-impl Render for RemoteConnectionModal {
-    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
-        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;
-
-        v_flex()
-            .elevation_3(cx)
-            .w(rems(34.))
-            .border_1()
-            .border_color(theme.colors().border)
-            .key_context("SshConnectionModal")
-            .track_focus(&self.focus_handle(cx))
-            .on_action(cx.listener(Self::dismiss))
-            .on_action(cx.listener(Self::confirm))
-            .child(
-                SshConnectionHeader {
-                    paths: self.paths.clone(),
-                    connection_string,
-                    nickname,
-                    is_wsl,
-                    is_devcontainer,
-                }
-                .render(window, cx),
-            )
-            .child(
-                div()
-                    .w_full()
-                    .bg(body_color)
-                    .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);
-                        })),
-                ),
-            )
-    }
-}
-
-impl Focusable for RemoteConnectionModal {
-    fn focus_handle(&self, cx: &gpui::App) -> gpui::FocusHandle {
-        self.prompt.read(cx).editor.focus_handle(cx)
-    }
-}
-
-impl EventEmitter<DismissEvent> for RemoteConnectionModal {}
-
-impl ModalView for RemoteConnectionModal {
-    fn on_before_dismiss(
-        &mut self,
-        _window: &mut Window,
-        _: &mut Context<Self>,
-    ) -> workspace::DismissDecision {
-        workspace::DismissDecision::Dismiss(self.finished)
-    }
-
-    fn fade_out_background(&self) -> bool {
-        true
-    }
-}
-
-#[derive(Clone)]
-pub struct RemoteClientDelegate {
-    window: AnyWindowHandle,
-    ui: WeakEntity<RemoteConnectionPrompt>,
-    known_password: Option<EncryptedPassword>,
-}
-
-impl remote::RemoteClientDelegate for RemoteClientDelegate {
-    fn ask_password(
-        &self,
-        prompt: String,
-        tx: oneshot::Sender<EncryptedPassword>,
-        cx: &mut AsyncApp,
-    ) {
-        let mut known_password = self.known_password.clone();
-        if let Some(password) = known_password.take() {
-            tx.send(password).ok();
-        } else {
-            self.window
-                .update(cx, |_, window, cx| {
-                    self.ui.update(cx, |modal, cx| {
-                        modal.set_prompt(prompt, tx, window, cx);
-                    })
-                })
-                .ok();
-        }
-    }
-
-    fn set_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
-        self.update_status(status, cx)
-    }
-
-    fn download_server_binary_locally(
-        &self,
-        platform: RemotePlatform,
-        release_channel: ReleaseChannel,
-        version: Option<Version>,
-        cx: &mut AsyncApp,
-    ) -> Task<anyhow::Result<PathBuf>> {
-        let this = self.clone();
-        cx.spawn(async move |cx| {
-            AutoUpdater::download_remote_server_release(
-                release_channel,
-                version.clone(),
-                platform.os.as_str(),
-                platform.arch.as_str(),
-                move |status, cx| this.set_status(Some(status), cx),
-                cx,
-            )
-            .await
-            .with_context(|| {
-                format!(
-                    "Downloading remote server binary (version: {}, os: {}, arch: {})",
-                    version
-                        .as_ref()
-                        .map(|v| format!("{}", v))
-                        .unwrap_or("unknown".to_string()),
-                    platform.os,
-                    platform.arch,
-                )
-            })
-        })
-    }
-
-    fn get_download_url(
-        &self,
-        platform: RemotePlatform,
-        release_channel: ReleaseChannel,
-        version: Option<Version>,
-        cx: &mut AsyncApp,
-    ) -> Task<Result<Option<String>>> {
-        cx.spawn(async move |cx| {
-            AutoUpdater::get_remote_server_release_url(
-                release_channel,
-                version,
-                platform.os.as_str(),
-                platform.arch.as_str(),
-                cx,
-            )
-            .await
-        })
-    }
-}
-
-impl RemoteClientDelegate {
-    fn update_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
-        cx.update(|cx| {
-            self.ui
-                .update(cx, |modal, cx| {
-                    modal.set_status(status.map(|s| s.to_string()), cx);
-                })
-                .ok()
-        });
-    }
-}
-
-pub fn connect(
-    unique_identifier: ConnectionIdentifier,
-    connection_options: RemoteConnectionOptions,
-    ui: Entity<RemoteConnectionPrompt>,
-    window: &mut Window,
-    cx: &mut App,
-) -> Task<Result<Option<Entity<RemoteClient>>>> {
-    let window = window.window_handle();
-    let known_password = match &connection_options {
-        RemoteConnectionOptions::Ssh(ssh_connection_options) => ssh_connection_options
-            .password
-            .as_deref()
-            .and_then(|pw| pw.try_into().ok()),
-        _ => None,
-    };
-    let (tx, mut rx) = oneshot::channel();
-    ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx));
-
-    let delegate = Arc::new(RemoteClientDelegate {
-        window,
-        ui: ui.downgrade(),
-        known_password,
-    });
-
-    cx.spawn(async move |cx| {
-        let connection = remote::connect(connection_options, delegate.clone(), cx);
-        let connection = select! {
-            _ = rx => return Ok(None),
-            result = connection.fuse() => result,
-        }?;
-
-        cx.update(|cx| remote::RemoteClient::new(unique_identifier, connection, rx, delegate, cx))
-            .await
-    })
-}
-
 pub async fn open_remote_project(
     connection_options: RemoteConnectionOptions,
     paths: Vec<PathBuf>,
@@ -641,7 +135,6 @@ pub async fn open_remote_project(
     } else {
         let workspace_position = cx
             .update(|cx| {
-                // todo: These paths are wrong they may have column and line information
                 workspace::remote_workspace_position_from_db(connection_options.clone(), &paths, cx)
             })
             .await
@@ -695,12 +188,10 @@ pub async fn open_remote_project(
                     ui.set_cancellation_tx(cancel_tx);
                 });
 
-                Some(Arc::new(RemoteClientDelegate {
-                    window: window.window_handle(),
-                    ui: ui.downgrade(),
-                    known_password: if let RemoteConnectionOptions::Ssh(options) =
-                        &connection_options
-                    {
+                Some(Arc::new(RemoteClientDelegate::new(
+                    window.window_handle(),
+                    ui.downgrade(),
+                    if let RemoteConnectionOptions::Ssh(options) = &connection_options {
                         options
                             .password
                             .as_deref()
@@ -708,7 +199,7 @@ pub async fn open_remote_project(
                     } else {
                         None
                     },
-                }))
+                )))
             }
         })?;
 
@@ -884,7 +375,6 @@ pub async fn open_remote_project(
             }
         })
         .ok();
-    // Already showed the error to the user
     Ok(())
 }
 
@@ -935,7 +425,7 @@ mod tests {
     use super::*;
     use extension::ExtensionHostProxy;
     use fs::FakeFs;
-    use gpui::TestAppContext;
+    use gpui::{AppContext, TestAppContext};
     use http_client::BlockedHttpClient;
     use node_runtime::NodeRuntime;
     use remote::RemoteClient;

crates/remote_connection/Cargo.toml 🔗

@@ -0,0 +1,34 @@
+[package]
+name = "remote_connection"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/remote_connection.rs"
+
+[features]
+default = []
+test-support = ["remote/test-support"]
+
+[dependencies]
+anyhow.workspace = true
+askpass.workspace = true
+auto_update.workspace = true
+futures.workspace = true
+gpui.workspace = true
+log.workspace = true
+markdown.workspace = true
+menu.workspace = true
+release_channel.workspace = true
+remote.workspace = true
+semver.workspace = true
+settings.workspace = true
+theme.workspace = true
+ui.workspace = true
+ui_input.workspace = true
+workspace.workspace = true

crates/remote_connection/src/remote_connection.rs 🔗

@@ -0,0 +1,522 @@
+use std::{path::PathBuf, sync::Arc};
+
+use anyhow::Result;
+use askpass::EncryptedPassword;
+use auto_update::AutoUpdater;
+use futures::{FutureExt as _, channel::oneshot, select};
+use gpui::{
+    AnyWindowHandle, App, AsyncApp, DismissEvent, Entity, EventEmitter, Focusable, FontFeatures,
+    ParentElement as _, Render, SharedString, Task, TextStyleRefinement, WeakEntity,
+};
+use markdown::{Markdown, MarkdownElement, MarkdownStyle};
+use release_channel::ReleaseChannel;
+use remote::{ConnectionIdentifier, RemoteClient, RemoteConnectionOptions, RemotePlatform};
+use semver::Version;
+use settings::Settings;
+use theme::ThemeSettings;
+use ui::{
+    ActiveTheme, Color, CommonAnimationExt, Context, InteractiveElement, IntoElement, KeyBinding,
+    LabelCommon, ListItem, Styled, Window, prelude::*,
+};
+use ui_input::{ERASED_EDITOR_FACTORY, ErasedEditor};
+use workspace::{DismissDecision, ModalView};
+
+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<()>>,
+    editor: Arc<dyn ErasedEditor>,
+}
+
+impl Drop for RemoteConnectionPrompt {
+    fn drop(&mut self) {
+        if let Some(cancel) = self.cancellation.take() {
+            log::debug!("cancelling remote connection");
+            cancel.send(()).ok();
+        }
+    }
+}
+
+pub struct RemoteConnectionModal {
+    pub prompt: Entity<RemoteConnectionPrompt>,
+    paths: Vec<PathBuf>,
+    finished: bool,
+}
+
+impl RemoteConnectionPrompt {
+    pub fn new(
+        connection_string: String,
+        nickname: Option<String>,
+        is_wsl: bool,
+        is_devcontainer: bool,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let editor_factory = ERASED_EDITOR_FACTORY
+            .get()
+            .expect("ErasedEditorFactory to be initialized");
+        let editor = (editor_factory)(window, cx);
+
+        Self {
+            connection_string: connection_string.into(),
+            nickname: nickname.map(|nickname| nickname.into()),
+            is_wsl,
+            is_devcontainer,
+            editor,
+            status_message: None,
+            cancellation: None,
+            prompt: None,
+        }
+    }
+
+    pub fn set_cancellation_tx(&mut self, tx: oneshot::Sender<()>) {
+        self.cancellation = Some(tx);
+    }
+
+    pub fn set_prompt(
+        &mut self,
+        prompt: String,
+        tx: oneshot::Sender<EncryptedPassword>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let is_yes_no = prompt.contains("yes/no");
+        self.editor.set_masked(!is_yes_no, window, cx);
+
+        let markdown = cx.new(|cx| Markdown::new_text(prompt.into(), cx));
+        self.prompt = Some((markdown, tx));
+        self.status_message.take();
+        window.focus(&self.editor.focus_handle(cx), cx);
+        cx.notify();
+    }
+
+    pub fn set_status(&mut self, status: Option<String>, cx: &mut Context<Self>) {
+        self.status_message = status.map(|s| s.into());
+        cx.notify();
+    }
+
+    pub fn confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        if let Some((_, tx)) = self.prompt.take() {
+            self.status_message = Some("Connecting".into());
+
+            let pw = self.editor.text(cx);
+            if let Ok(secure) = EncryptedPassword::try_from(pw.as_ref()) {
+                tx.send(secure).ok();
+            }
+            self.editor.clear(window, cx);
+        }
+    }
+}
+
+impl Render for RemoteConnectionPrompt {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let theme = ThemeSettings::get_global(cx);
+
+        let mut text_style = window.text_style();
+        let refinement = TextStyleRefinement {
+            font_family: Some(theme.buffer_font.family.clone()),
+            font_features: Some(FontFeatures::disable_ligatures()),
+            font_size: Some(theme.buffer_font_size(cx).into()),
+            color: Some(cx.theme().colors().editor_foreground),
+            background_color: Some(gpui::transparent_black()),
+            ..Default::default()
+        };
+
+        text_style.refine(&refinement);
+        let markdown_style = MarkdownStyle {
+            base_text_style: text_style,
+            selection_background_color: cx.theme().colors().element_selection_background,
+            ..Default::default()
+        };
+
+        v_flex()
+            .key_context("PasswordPrompt")
+            .p_2()
+            .size_full()
+            .text_buffer(cx)
+            .when_some(self.status_message.clone(), |el, status_message| {
+                el.child(
+                    h_flex()
+                        .gap_2()
+                        .child(
+                            Icon::new(IconName::ArrowCircle)
+                                .color(Color::Muted)
+                                .with_rotate_animation(2),
+                        )
+                        .child(
+                            div()
+                                .text_ellipsis()
+                                .overflow_x_hidden()
+                                .child(format!("{}…", status_message)),
+                        ),
+                )
+            })
+            .when_some(self.prompt.as_ref(), |el, prompt| {
+                el.child(
+                    div()
+                        .size_full()
+                        .overflow_hidden()
+                        .child(MarkdownElement::new(prompt.0.clone(), markdown_style))
+                        .child(self.editor.render(window, cx)),
+                )
+                .when(window.capslock().on, |el| {
+                    el.child(Label::new("⚠️ ⇪ is on"))
+                })
+            })
+    }
+}
+
+impl RemoteConnectionModal {
+    pub fn new(
+        connection_options: &RemoteConnectionOptions,
+        paths: Vec<PathBuf>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        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::Docker(options) => (options.name.clone(), None, false, true),
+            #[cfg(any(test, feature = "test-support"))]
+            RemoteConnectionOptions::Mock(options) => {
+                (format!("mock-{}", options.id), None, false, false)
+            }
+        };
+        Self {
+            prompt: cx.new(|cx| {
+                RemoteConnectionPrompt::new(
+                    connection_string,
+                    nickname,
+                    is_wsl,
+                    is_devcontainer,
+                    window,
+                    cx,
+                )
+            }),
+            finished: false,
+            paths,
+        }
+    }
+
+    fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
+        self.prompt
+            .update(cx, |prompt, cx| prompt.confirm(window, cx))
+    }
+
+    pub fn finished(&mut self, cx: &mut Context<Self>) {
+        self.finished = true;
+        cx.emit(DismissEvent);
+    }
+
+    fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
+        if let Some(tx) = self
+            .prompt
+            .update(cx, |prompt, _cx| prompt.cancellation.take())
+        {
+            log::debug!("cancelling remote connection");
+            tx.send(()).ok();
+        }
+        self.finished(cx);
+    }
+}
+
+pub struct SshConnectionHeader {
+    pub connection_string: SharedString,
+    pub paths: Vec<PathBuf>,
+    pub nickname: Option<SharedString>,
+    pub is_wsl: bool,
+    pub is_devcontainer: bool,
+}
+
+impl RenderOnce for SshConnectionHeader {
+    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+        let theme = cx.theme();
+
+        let mut header_color = theme.colors().text;
+        header_color.fade_out(0.96);
+
+        let (main_label, meta_label) = if let Some(nickname) = self.nickname {
+            (nickname, Some(format!("({})", self.connection_string)))
+        } else {
+            (self.connection_string, None)
+        };
+
+        let icon = if self.is_wsl {
+            IconName::Linux
+        } else if self.is_devcontainer {
+            IconName::Box
+        } else {
+            IconName::Server
+        };
+
+        h_flex()
+            .px(DynamicSpacing::Base12.rems(cx))
+            .pt(DynamicSpacing::Base08.rems(cx))
+            .pb(DynamicSpacing::Base04.rems(cx))
+            .rounded_t_sm()
+            .w_full()
+            .gap_1p5()
+            .child(Icon::new(icon).size(IconSize::Small))
+            .child(
+                h_flex()
+                    .gap_1()
+                    .overflow_x_hidden()
+                    .child(
+                        div()
+                            .max_w_96()
+                            .overflow_x_hidden()
+                            .text_ellipsis()
+                            .child(Headline::new(main_label).size(HeadlineSize::XSmall)),
+                    )
+                    .children(
+                        meta_label.map(|label| {
+                            Label::new(label).color(Color::Muted).size(LabelSize::Small)
+                        }),
+                    )
+                    .child(div().overflow_x_hidden().text_ellipsis().children(
+                        self.paths.into_iter().map(|path| {
+                            Label::new(path.to_string_lossy().into_owned())
+                                .size(LabelSize::Small)
+                                .color(Color::Muted)
+                        }),
+                    )),
+            )
+    }
+}
+
+impl Render for RemoteConnectionModal {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
+        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;
+
+        v_flex()
+            .elevation_3(cx)
+            .w(rems(34.))
+            .border_1()
+            .border_color(theme.colors().border)
+            .key_context("SshConnectionModal")
+            .track_focus(&self.focus_handle(cx))
+            .on_action(cx.listener(Self::dismiss))
+            .on_action(cx.listener(Self::confirm))
+            .child(
+                SshConnectionHeader {
+                    paths: self.paths.clone(),
+                    connection_string,
+                    nickname,
+                    is_wsl,
+                    is_devcontainer,
+                }
+                .render(window, cx),
+            )
+            .child(
+                div()
+                    .w_full()
+                    .bg(body_color)
+                    .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);
+                        })),
+                ),
+            )
+    }
+}
+
+impl Focusable for RemoteConnectionModal {
+    fn focus_handle(&self, cx: &gpui::App) -> gpui::FocusHandle {
+        self.prompt.read(cx).editor.focus_handle(cx)
+    }
+}
+
+impl EventEmitter<DismissEvent> for RemoteConnectionModal {}
+
+impl ModalView for RemoteConnectionModal {
+    fn on_before_dismiss(
+        &mut self,
+        _window: &mut Window,
+        _: &mut Context<Self>,
+    ) -> DismissDecision {
+        DismissDecision::Dismiss(self.finished)
+    }
+
+    fn fade_out_background(&self) -> bool {
+        true
+    }
+}
+
+#[derive(Clone)]
+pub struct RemoteClientDelegate {
+    window: AnyWindowHandle,
+    ui: WeakEntity<RemoteConnectionPrompt>,
+    known_password: Option<EncryptedPassword>,
+}
+
+impl RemoteClientDelegate {
+    pub fn new(
+        window: AnyWindowHandle,
+        ui: WeakEntity<RemoteConnectionPrompt>,
+        known_password: Option<EncryptedPassword>,
+    ) -> Self {
+        Self {
+            window,
+            ui,
+            known_password,
+        }
+    }
+}
+
+impl remote::RemoteClientDelegate for RemoteClientDelegate {
+    fn ask_password(
+        &self,
+        prompt: String,
+        tx: oneshot::Sender<EncryptedPassword>,
+        cx: &mut AsyncApp,
+    ) {
+        let mut known_password = self.known_password.clone();
+        if let Some(password) = known_password.take() {
+            tx.send(password).ok();
+        } else {
+            self.window
+                .update(cx, |_, window, cx| {
+                    self.ui.update(cx, |modal, cx| {
+                        modal.set_prompt(prompt, tx, window, cx);
+                    })
+                })
+                .ok();
+        }
+    }
+
+    fn set_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
+        self.update_status(status, cx)
+    }
+
+    fn download_server_binary_locally(
+        &self,
+        platform: RemotePlatform,
+        release_channel: ReleaseChannel,
+        version: Option<Version>,
+        cx: &mut AsyncApp,
+    ) -> Task<anyhow::Result<PathBuf>> {
+        let this = self.clone();
+        cx.spawn(async move |cx| {
+            AutoUpdater::download_remote_server_release(
+                release_channel,
+                version.clone(),
+                platform.os.as_str(),
+                platform.arch.as_str(),
+                move |status, cx| this.set_status(Some(status), cx),
+                cx,
+            )
+            .await
+            .with_context(|| {
+                format!(
+                    "Downloading remote server binary (version: {}, os: {}, arch: {})",
+                    version
+                        .as_ref()
+                        .map(|v| format!("{}", v))
+                        .unwrap_or("unknown".to_string()),
+                    platform.os,
+                    platform.arch,
+                )
+            })
+        })
+    }
+
+    fn get_download_url(
+        &self,
+        platform: RemotePlatform,
+        release_channel: ReleaseChannel,
+        version: Option<Version>,
+        cx: &mut AsyncApp,
+    ) -> Task<Result<Option<String>>> {
+        cx.spawn(async move |cx| {
+            AutoUpdater::get_remote_server_release_url(
+                release_channel,
+                version,
+                platform.os.as_str(),
+                platform.arch.as_str(),
+                cx,
+            )
+            .await
+        })
+    }
+}
+
+impl RemoteClientDelegate {
+    fn update_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
+        cx.update(|cx| {
+            self.ui
+                .update(cx, |modal, cx| {
+                    modal.set_status(status.map(|s| s.to_string()), cx);
+                })
+                .ok()
+        });
+    }
+}
+
+pub fn connect(
+    unique_identifier: ConnectionIdentifier,
+    connection_options: RemoteConnectionOptions,
+    ui: Entity<RemoteConnectionPrompt>,
+    window: &mut Window,
+    cx: &mut App,
+) -> Task<Result<Option<Entity<RemoteClient>>>> {
+    let window = window.window_handle();
+    let known_password = match &connection_options {
+        RemoteConnectionOptions::Ssh(ssh_connection_options) => ssh_connection_options
+            .password
+            .as_deref()
+            .and_then(|pw| pw.try_into().ok()),
+        _ => None,
+    };
+    let (tx, mut rx) = oneshot::channel();
+    ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx));
+
+    let delegate = Arc::new(RemoteClientDelegate {
+        window,
+        ui: ui.downgrade(),
+        known_password,
+    });
+
+    cx.spawn(async move |cx| {
+        let connection = remote::connect(connection_options, delegate.clone(), cx);
+        let connection = select! {
+            _ = rx => return Ok(None),
+            result = connection.fuse() => result,
+        }?;
+
+        cx.update(|cx| remote::RemoteClient::new(unique_identifier, connection, rx, delegate, cx))
+            .await
+    })
+}
+
+use anyhow::Context as _;

crates/ui_input/src/ui_input.rs 🔗

@@ -19,6 +19,7 @@ pub trait ErasedEditor: 'static {
     fn clear(&self, window: &mut Window, cx: &mut App);
     fn set_placeholder_text(&self, text: &str, window: &mut Window, _: &mut App);
     fn move_selection_to_end(&self, window: &mut Window, _: &mut App);
+    fn set_masked(&self, masked: bool, window: &mut Window, cx: &mut App);
 
     fn focus_handle(&self, cx: &App) -> FocusHandle;