From e99c11dee62743ebdc4bdc38c53b352c08b39fed Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Mon, 2 Feb 2026 13:30:07 +0100 Subject: [PATCH] build: Decouple git_ui from recent_projects (#48062) - **git_ui: Decouple git_ui from the recent_projects crate** - **Move git_ui closer to editor** Release Notes: - N/A --- 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 +- .../recent_projects/src/remote_connections.rs | 542 +----------------- crates/remote_connection/Cargo.toml | 34 ++ crates/remote_connection/LICENSE-GPL | 1 + .../src/remote_connection.rs | 522 +++++++++++++++++ crates/ui_input/src/ui_input.rs | 1 + 16 files changed, 670 insertions(+), 615 deletions(-) create mode 100644 crates/remote_connection/Cargo.toml create mode 120000 crates/remote_connection/LICENSE-GPL create mode 100644 crates/remote_connection/src/remote_connection.rs diff --git a/Cargo.lock b/Cargo.lock index c69c844bad05a85e3b87eb08f324f83deec47c56..e969208a047a8b1e477b01bde0014d390cba1eec 100644 --- a/Cargo.lock +++ b/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" diff --git a/Cargo.toml b/Cargo.toml index 054f6324629626cd1ff1d2a5aaa78186af1e0c86..cf2041bcf4e7ad56132562c71422f9300f5d66c6 100644 --- a/Cargo.toml +++ b/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" } diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 7282ad6cce76fe4d8e35d802bc0514395a7bc42a..624383d8b9fd95b2e135c5269deddaaee02bd391 100644 --- a/crates/editor/src/editor.rs +++ b/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 Default for InvalidationStack { fn default() -> Self { diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index ae364fa55928f83f442ac700a6f4a22aece83724..b9bd31fb8307b8105badc95477daf54f4001e9e9 100644 --- a/crates/git_ui/Cargo.toml +++ b/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"] diff --git a/crates/git_ui/src/commit_modal.rs b/crates/git_ui/src/commit_modal.rs index c7f659147645be8e9145bcf15d5f9273d496a6ad..57c25681439f9bb8ea7e5761c01d4c1a9defd427 100644 --- a/crates/git_ui/src/commit_modal.rs +++ b/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::{ diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 33ad03ffcaaa349b206f4eda59ef4689d66b3d0b..a16620407f8f0823ba77320e3da23cb81654c6ec 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/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>, } diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs index 56faecc6bc9bda52c8103b5ca998b852685282dd..f65eb80e582e20121d4aab2a6b2471784ade45a5 100644 --- a/crates/git_ui/src/worktree_picker.rs +++ b/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; diff --git a/crates/panel/Cargo.toml b/crates/panel/Cargo.toml index 3c51e6d6dcdb31922c07bd1d16923fdd10eeceb7..4e7c81804d32b329bbc701b5e068777ab24d4a5b 100644 --- a/crates/panel/Cargo.toml +++ b/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 diff --git a/crates/panel/src/panel.rs b/crates/panel/src/panel.rs index 1930f654e9b632e52719103e5b0a399cfe94f70a..133efa9cb61c122af79a228cdfb74f86e22792b4 100644 --- a/crates/panel/src/panel.rs +++ b/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, icon: IconName) -> ui::Ico pub fn panel_filled_icon_button(id: impl Into, 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, - monospace: bool, - window: &mut Window, - cx: &mut App, -) -> EditorElement { - EditorElement::new(editor, panel_editor_style(monospace, window, cx)) -} diff --git a/crates/recent_projects/Cargo.toml b/crates/recent_projects/Cargo.toml index cda29486ee01c3dbd2ed38725349fb4b5ec299d7..24e24675f7f09e02b5c59bc57064d0ee600dddcc 100644 --- a/crates/recent_projects/Cargo.toml +++ b/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 diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index e87c718dbd5caaa76b66e4de46169f95850c56eb..c5b668720a9a657a5bf5264dbdc991cd30b4fbb7 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/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}; diff --git a/crates/recent_projects/src/remote_connections.rs b/crates/recent_projects/src/remote_connections.rs index 559973412514b9bc13bdeeac2ac884e5c109f278..96b6981577507b3e9024889dc8f144fdc8f4f0f1 100644 --- a/crates/recent_projects/src/remote_connections.rs +++ b/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, - is_wsl: bool, - is_devcontainer: bool, - status_message: Option, - prompt: Option<(Entity, oneshot::Sender)>, - cancellation: Option>, - editor: Entity, -} - -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, - paths: Vec, - finished: bool, -} - -impl RemoteConnectionPrompt { - pub(crate) fn new( - connection_string: String, - nickname: Option, - is_wsl: bool, - is_devcontainer: bool, - window: &mut Window, - cx: &mut Context, - ) -> 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, - window: &mut Window, - cx: &mut Context, - ) { - 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, cx: &mut Context) { - self.status_message = status.map(|s| s.into()); - cx.notify(); - } - - pub fn confirm(&mut self, window: &mut Window, cx: &mut Context) { - 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) -> 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, - window: &mut Window, - cx: &mut Context, - ) -> 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.prompt - .update(cx, |prompt, cx| prompt.confirm(window, cx)) - } - - pub fn finished(&mut self, cx: &mut Context) { - self.finished = true; - cx.emit(DismissEvent); - } - - fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) { - 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, - pub(crate) nickname: Option, - 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) -> 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 for RemoteConnectionModal {} - -impl ModalView for RemoteConnectionModal { - fn on_before_dismiss( - &mut self, - _window: &mut Window, - _: &mut Context, - ) -> workspace::DismissDecision { - workspace::DismissDecision::Dismiss(self.finished) - } - - fn fade_out_background(&self) -> bool { - true - } -} - -#[derive(Clone)] -pub struct RemoteClientDelegate { - window: AnyWindowHandle, - ui: WeakEntity, - known_password: Option, -} - -impl remote::RemoteClientDelegate for RemoteClientDelegate { - fn ask_password( - &self, - prompt: String, - tx: oneshot::Sender, - 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, - cx: &mut AsyncApp, - ) -> Task> { - 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, - cx: &mut AsyncApp, - ) -> Task>> { - 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, - window: &mut Window, - cx: &mut App, -) -> Task>>> { - 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, @@ -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; diff --git a/crates/remote_connection/Cargo.toml b/crates/remote_connection/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..53e20eb5eb0708252a90819d37b38e214aa95d67 --- /dev/null +++ b/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 \ No newline at end of file diff --git a/crates/remote_connection/LICENSE-GPL b/crates/remote_connection/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/remote_connection/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/remote_connection/src/remote_connection.rs b/crates/remote_connection/src/remote_connection.rs new file mode 100644 index 0000000000000000000000000000000000000000..d4df85d7b94b52c6f6bef0f052e515797b4f79c3 --- /dev/null +++ b/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, + is_wsl: bool, + is_devcontainer: bool, + status_message: Option, + prompt: Option<(Entity, oneshot::Sender)>, + cancellation: Option>, + editor: Arc, +} + +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, + paths: Vec, + finished: bool, +} + +impl RemoteConnectionPrompt { + pub fn new( + connection_string: String, + nickname: Option, + is_wsl: bool, + is_devcontainer: bool, + window: &mut Window, + cx: &mut Context, + ) -> 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, + window: &mut Window, + cx: &mut Context, + ) { + 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, cx: &mut Context) { + self.status_message = status.map(|s| s.into()); + cx.notify(); + } + + pub fn confirm(&mut self, window: &mut Window, cx: &mut Context) { + 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) -> 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, + window: &mut Window, + cx: &mut Context, + ) -> 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.prompt + .update(cx, |prompt, cx| prompt.confirm(window, cx)) + } + + pub fn finished(&mut self, cx: &mut Context) { + self.finished = true; + cx.emit(DismissEvent); + } + + fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) { + 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, + pub nickname: Option, + 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) -> 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 for RemoteConnectionModal {} + +impl ModalView for RemoteConnectionModal { + fn on_before_dismiss( + &mut self, + _window: &mut Window, + _: &mut Context, + ) -> DismissDecision { + DismissDecision::Dismiss(self.finished) + } + + fn fade_out_background(&self) -> bool { + true + } +} + +#[derive(Clone)] +pub struct RemoteClientDelegate { + window: AnyWindowHandle, + ui: WeakEntity, + known_password: Option, +} + +impl RemoteClientDelegate { + pub fn new( + window: AnyWindowHandle, + ui: WeakEntity, + known_password: Option, + ) -> Self { + Self { + window, + ui, + known_password, + } + } +} + +impl remote::RemoteClientDelegate for RemoteClientDelegate { + fn ask_password( + &self, + prompt: String, + tx: oneshot::Sender, + 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, + cx: &mut AsyncApp, + ) -> Task> { + 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, + cx: &mut AsyncApp, + ) -> Task>> { + 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, + window: &mut Window, + cx: &mut App, +) -> Task>>> { + 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 _; diff --git a/crates/ui_input/src/ui_input.rs b/crates/ui_input/src/ui_input.rs index 769f24b32ea886980179430c6fd762ac7881322f..ab3addc35c19006cc3b0e0c5dc7c452fe39333a1 100644 --- a/crates/ui_input/src/ui_input.rs +++ b/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;