Detailed changes
@@ -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"
@@ -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" }
@@ -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 {
@@ -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"]
@@ -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::{
@@ -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>>,
}
@@ -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;
@@ -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
@@ -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))
-}
@@ -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
@@ -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};
@@ -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;
@@ -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
@@ -0,0 +1 @@
+../../LICENSE-GPL
@@ -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 _;
@@ -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;