
## Problem statement I want to add keyboard navigation support to SSH modal. Doing so is possible in current landscape, but not particularly ergonomic; `gpui::ScrollHandle` has `scroll_to_item` API that takes an index of the item you want to scroll to. The problem is, however, that it only works with it's immediate children - thus in order to support scrolling via keyboard you have to bend your UI to have a particular layout. Even when your list of items is perfectly flat, having decorations inbetween items is problematic as they are also children of the list, which means that you either have to maintain the mapping to devise a correct index of an item that you want to scroll to, or you have to make the decoration a part of the list item itself, which might render the scrolling imprecise (you might e.g. not want to scroll to a header, but to a button beneath it). ## The solution This PR adds `ScrollAnchor`, a new kind of handle to the gpui. It has a similar role to that of a ScrollHandle, but instead of tracking how far along an item has been scrolled, it tracks position of an element relative to the parent to which a given scroll handle belongs. In short, it allows us to persist the position of an element in a list of items and scroll to it even if it's not an immediate children of a container whose scroll position is tracked via an associated scroll handle. Additionally this PR adds a new kind of the container to the UI crate that serves as a convenience wrapper for using ScrollAnchors. This container provides handlers for `menu::SelectNext` and `menu::SelectPrev` and figures out which item should be focused next. Release Notes: - Improve keyboard navigation in ssh modal
661 lines
21 KiB
Rust
661 lines
21 KiB
Rust
use std::{path::PathBuf, sync::Arc, time::Duration};
|
|
|
|
use anyhow::{anyhow, Result};
|
|
use auto_update::AutoUpdater;
|
|
use editor::Editor;
|
|
use futures::channel::oneshot;
|
|
use gpui::{
|
|
percentage, Animation, AnimationExt, AnyWindowHandle, AsyncAppContext, DismissEvent,
|
|
EventEmitter, FocusableView, ParentElement as _, PromptLevel, Render, SemanticVersion,
|
|
SharedString, Task, TextStyleRefinement, Transformation, View, WeakView,
|
|
};
|
|
use gpui::{AppContext, Model};
|
|
|
|
use language::CursorShape;
|
|
use markdown::{Markdown, MarkdownStyle};
|
|
use release_channel::ReleaseChannel;
|
|
use remote::{SshConnectionOptions, SshPlatform, SshRemoteClient};
|
|
use schemars::JsonSchema;
|
|
use serde::{Deserialize, Serialize};
|
|
use settings::{Settings, SettingsSources};
|
|
use theme::ThemeSettings;
|
|
use ui::{
|
|
prelude::*, ActiveTheme, Color, Icon, IconName, IconSize, InteractiveElement, IntoElement,
|
|
Label, LabelCommon, Styled, ViewContext, VisualContext, WindowContext,
|
|
};
|
|
use workspace::{AppState, ModalView, Workspace};
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct SshSettings {
|
|
pub ssh_connections: Option<Vec<SshConnection>>,
|
|
}
|
|
|
|
impl SshSettings {
|
|
pub fn ssh_connections(&self) -> impl Iterator<Item = SshConnection> {
|
|
self.ssh_connections.clone().into_iter().flatten()
|
|
}
|
|
|
|
pub fn connection_options_for(
|
|
&self,
|
|
host: String,
|
|
port: Option<u16>,
|
|
username: Option<String>,
|
|
) -> SshConnectionOptions {
|
|
for conn in self.ssh_connections() {
|
|
if conn.host == host && conn.username == username && conn.port == port {
|
|
return SshConnectionOptions {
|
|
nickname: conn.nickname,
|
|
upload_binary_over_ssh: conn.upload_binary_over_ssh.unwrap_or_default(),
|
|
args: Some(conn.args),
|
|
host,
|
|
port,
|
|
username,
|
|
password: None,
|
|
};
|
|
}
|
|
}
|
|
SshConnectionOptions {
|
|
host,
|
|
port,
|
|
username,
|
|
..Default::default()
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Default, Serialize, Deserialize, PartialEq, JsonSchema)]
|
|
pub struct SshConnection {
|
|
pub host: SharedString,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub username: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub port: Option<u16>,
|
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
|
#[serde(default)]
|
|
pub args: Vec<String>,
|
|
#[serde(default)]
|
|
pub projects: Vec<SshProject>,
|
|
/// Name to use for this server in UI.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub nickname: Option<String>,
|
|
// By default Zed will download the binary to the host directly.
|
|
// If this is set to true, Zed will download the binary to your local machine,
|
|
// and then upload it over the SSH connection. Useful if your SSH server has
|
|
// limited outbound internet access.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub upload_binary_over_ssh: Option<bool>,
|
|
}
|
|
|
|
impl From<SshConnection> for SshConnectionOptions {
|
|
fn from(val: SshConnection) -> Self {
|
|
SshConnectionOptions {
|
|
host: val.host.into(),
|
|
username: val.username,
|
|
port: val.port,
|
|
password: None,
|
|
args: Some(val.args),
|
|
nickname: val.nickname,
|
|
upload_binary_over_ssh: val.upload_binary_over_ssh.unwrap_or_default(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Default, Serialize, PartialEq, Deserialize, JsonSchema)]
|
|
pub struct SshProject {
|
|
pub paths: Vec<String>,
|
|
}
|
|
|
|
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
|
|
pub struct RemoteSettingsContent {
|
|
pub ssh_connections: Option<Vec<SshConnection>>,
|
|
}
|
|
|
|
impl Settings for SshSettings {
|
|
const KEY: Option<&'static str> = None;
|
|
|
|
type FileContent = RemoteSettingsContent;
|
|
|
|
fn load(sources: SettingsSources<Self::FileContent>, _: &mut AppContext) -> Result<Self> {
|
|
sources.json_merge()
|
|
}
|
|
}
|
|
|
|
pub struct SshPrompt {
|
|
connection_string: SharedString,
|
|
nickname: Option<SharedString>,
|
|
status_message: Option<SharedString>,
|
|
prompt: Option<(View<Markdown>, oneshot::Sender<Result<String>>)>,
|
|
cancellation: Option<oneshot::Sender<()>>,
|
|
editor: View<Editor>,
|
|
}
|
|
|
|
impl Drop for SshPrompt {
|
|
fn drop(&mut self) {
|
|
if let Some(cancel) = self.cancellation.take() {
|
|
cancel.send(()).ok();
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct SshConnectionModal {
|
|
pub(crate) prompt: View<SshPrompt>,
|
|
paths: Vec<PathBuf>,
|
|
finished: bool,
|
|
}
|
|
|
|
impl SshPrompt {
|
|
pub(crate) fn new(
|
|
connection_options: &SshConnectionOptions,
|
|
cx: &mut ViewContext<Self>,
|
|
) -> Self {
|
|
let connection_string = connection_options.connection_string().into();
|
|
let nickname = connection_options.nickname.clone().map(|s| s.into());
|
|
|
|
Self {
|
|
connection_string,
|
|
nickname,
|
|
editor: cx.new_view(Editor::single_line),
|
|
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<Result<String>>,
|
|
cx: &mut ViewContext<Self>,
|
|
) {
|
|
let theme = ThemeSettings::get_global(cx);
|
|
|
|
let mut text_style = cx.text_style();
|
|
let refinement = TextStyleRefinement {
|
|
font_family: Some(theme.buffer_font.family.clone()),
|
|
font_size: Some(theme.buffer_font_size.into()),
|
|
color: Some(cx.theme().colors().editor_foreground),
|
|
background_color: Some(gpui::transparent_black()),
|
|
..Default::default()
|
|
};
|
|
|
|
text_style.refine(&refinement);
|
|
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_style = MarkdownStyle {
|
|
base_text_style: text_style,
|
|
selection_background_color: cx.theme().players().local().selection,
|
|
..Default::default()
|
|
};
|
|
let markdown = cx.new_view(|cx| Markdown::new_text(prompt, markdown_style, None, cx, None));
|
|
self.prompt = Some((markdown, tx));
|
|
self.status_message.take();
|
|
cx.focus_view(&self.editor);
|
|
cx.notify();
|
|
}
|
|
|
|
pub fn set_status(&mut self, status: Option<String>, cx: &mut ViewContext<Self>) {
|
|
self.status_message = status.map(|s| s.into());
|
|
cx.notify();
|
|
}
|
|
|
|
pub fn confirm(&mut self, cx: &mut ViewContext<Self>) {
|
|
if let Some((_, tx)) = self.prompt.take() {
|
|
self.status_message = Some("Connecting".into());
|
|
self.editor.update(cx, |editor, cx| {
|
|
tx.send(Ok(editor.text(cx))).ok();
|
|
editor.clear(cx);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Render for SshPrompt {
|
|
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
|
let cx = cx.window_context();
|
|
|
|
v_flex()
|
|
.key_context("PasswordPrompt")
|
|
.py_2()
|
|
.px_3()
|
|
.size_full()
|
|
.text_buffer(cx)
|
|
.when_some(self.status_message.clone(), |el, status_message| {
|
|
el.child(
|
|
h_flex()
|
|
.gap_1()
|
|
.child(
|
|
Icon::new(IconName::ArrowCircle)
|
|
.size(IconSize::Medium)
|
|
.with_animation(
|
|
"arrow-circle",
|
|
Animation::new(Duration::from_secs(2)).repeat(),
|
|
|icon, delta| {
|
|
icon.transform(Transformation::rotate(percentage(delta)))
|
|
},
|
|
),
|
|
)
|
|
.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(prompt.0.clone())
|
|
.child(self.editor.clone()),
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
impl SshConnectionModal {
|
|
pub(crate) fn new(
|
|
connection_options: &SshConnectionOptions,
|
|
paths: Vec<PathBuf>,
|
|
cx: &mut ViewContext<Self>,
|
|
) -> Self {
|
|
Self {
|
|
prompt: cx.new_view(|cx| SshPrompt::new(connection_options, cx)),
|
|
finished: false,
|
|
paths,
|
|
}
|
|
}
|
|
|
|
fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
|
|
self.prompt.update(cx, |prompt, cx| prompt.confirm(cx))
|
|
}
|
|
|
|
pub fn finished(&mut self, cx: &mut ViewContext<Self>) {
|
|
self.finished = true;
|
|
cx.emit(DismissEvent);
|
|
}
|
|
|
|
fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
|
|
if let Some(tx) = self
|
|
.prompt
|
|
.update(cx, |prompt, _cx| prompt.cancellation.take())
|
|
{
|
|
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>,
|
|
}
|
|
|
|
impl RenderOnce for SshConnectionHeader {
|
|
fn render(self, cx: &mut WindowContext) -> 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)
|
|
};
|
|
|
|
h_flex()
|
|
.px(Spacing::XLarge.rems(cx))
|
|
.pt(Spacing::Large.rems(cx))
|
|
.pb(Spacing::Small.rems(cx))
|
|
.rounded_t_md()
|
|
.w_full()
|
|
.gap_1p5()
|
|
.child(Icon::new(IconName::Server).size(IconSize::XSmall))
|
|
.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().to_string())
|
|
.size(LabelSize::Small)
|
|
.color(Color::Muted)
|
|
}),
|
|
)),
|
|
)
|
|
}
|
|
}
|
|
|
|
impl Render for SshConnectionModal {
|
|
fn render(&mut self, cx: &mut ui::ViewContext<Self>) -> impl ui::IntoElement {
|
|
let nickname = self.prompt.read(cx).nickname.clone();
|
|
let connection_string = self.prompt.read(cx).connection_string.clone();
|
|
|
|
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,
|
|
}
|
|
.render(cx),
|
|
)
|
|
.child(
|
|
div()
|
|
.w_full()
|
|
.rounded_b_lg()
|
|
.bg(body_color)
|
|
.border_t_1()
|
|
.border_color(theme.colors().border_variant)
|
|
.child(self.prompt.clone()),
|
|
)
|
|
}
|
|
}
|
|
|
|
impl FocusableView for SshConnectionModal {
|
|
fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
|
|
self.prompt.read(cx).editor.focus_handle(cx)
|
|
}
|
|
}
|
|
|
|
impl EventEmitter<DismissEvent> for SshConnectionModal {}
|
|
|
|
impl ModalView for SshConnectionModal {
|
|
fn on_before_dismiss(&mut self, _: &mut ViewContext<Self>) -> workspace::DismissDecision {
|
|
return workspace::DismissDecision::Dismiss(self.finished);
|
|
}
|
|
|
|
fn fade_out_background(&self) -> bool {
|
|
true
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct SshClientDelegate {
|
|
window: AnyWindowHandle,
|
|
ui: WeakView<SshPrompt>,
|
|
known_password: Option<String>,
|
|
}
|
|
|
|
impl remote::SshClientDelegate for SshClientDelegate {
|
|
fn ask_password(
|
|
&self,
|
|
prompt: String,
|
|
cx: &mut AsyncAppContext,
|
|
) -> oneshot::Receiver<Result<String>> {
|
|
let (tx, rx) = oneshot::channel();
|
|
let mut known_password = self.known_password.clone();
|
|
if let Some(password) = known_password.take() {
|
|
tx.send(Ok(password)).ok();
|
|
} else {
|
|
self.window
|
|
.update(cx, |_, cx| {
|
|
self.ui.update(cx, |modal, cx| {
|
|
modal.set_prompt(prompt, tx, cx);
|
|
})
|
|
})
|
|
.ok();
|
|
}
|
|
rx
|
|
}
|
|
|
|
fn set_status(&self, status: Option<&str>, cx: &mut AsyncAppContext) {
|
|
self.update_status(status, cx)
|
|
}
|
|
|
|
fn download_server_binary_locally(
|
|
&self,
|
|
platform: SshPlatform,
|
|
release_channel: ReleaseChannel,
|
|
version: Option<SemanticVersion>,
|
|
cx: &mut AsyncAppContext,
|
|
) -> Task<anyhow::Result<PathBuf>> {
|
|
cx.spawn(|mut cx| async move {
|
|
let binary_path = AutoUpdater::download_remote_server_release(
|
|
platform.os,
|
|
platform.arch,
|
|
release_channel,
|
|
version,
|
|
&mut cx,
|
|
)
|
|
.await
|
|
.map_err(|e| {
|
|
anyhow!(
|
|
"Failed to download remote server binary (version: {}, os: {}, arch: {}): {}",
|
|
version
|
|
.map(|v| format!("{}", v))
|
|
.unwrap_or("unknown".to_string()),
|
|
platform.os,
|
|
platform.arch,
|
|
e
|
|
)
|
|
})?;
|
|
Ok(binary_path)
|
|
})
|
|
}
|
|
|
|
fn get_download_params(
|
|
&self,
|
|
platform: SshPlatform,
|
|
release_channel: ReleaseChannel,
|
|
version: Option<SemanticVersion>,
|
|
cx: &mut AsyncAppContext,
|
|
) -> Task<Result<(String, String)>> {
|
|
cx.spawn(|mut cx| async move {
|
|
let (release, request_body) = AutoUpdater::get_remote_server_release_url(
|
|
platform.os,
|
|
platform.arch,
|
|
release_channel,
|
|
version,
|
|
&mut cx,
|
|
)
|
|
.await
|
|
.map_err(|e| {
|
|
anyhow!(
|
|
"Failed to get remote server binary download url (version: {}, os: {}, arch: {}): {}",
|
|
version.map(|v| format!("{}", v)).unwrap_or("unknown".to_string()),
|
|
platform.os,
|
|
platform.arch,
|
|
e
|
|
)
|
|
})?;
|
|
|
|
Ok((release.url, request_body))
|
|
}
|
|
)
|
|
}
|
|
|
|
fn remote_server_binary_path(
|
|
&self,
|
|
platform: SshPlatform,
|
|
cx: &mut AsyncAppContext,
|
|
) -> Result<PathBuf> {
|
|
let release_channel = cx.update(|cx| ReleaseChannel::global(cx))?;
|
|
Ok(paths::remote_server_dir_relative().join(format!(
|
|
"zed-remote-server-{}-{}-{}",
|
|
release_channel.dev_name(),
|
|
platform.os,
|
|
platform.arch
|
|
)))
|
|
}
|
|
}
|
|
|
|
impl SshClientDelegate {
|
|
fn update_status(&self, status: Option<&str>, cx: &mut AsyncAppContext) {
|
|
self.window
|
|
.update(cx, |_, cx| {
|
|
self.ui.update(cx, |modal, cx| {
|
|
modal.set_status(status.map(|s| s.to_string()), cx);
|
|
})
|
|
})
|
|
.ok();
|
|
}
|
|
}
|
|
|
|
pub fn is_connecting_over_ssh(workspace: &Workspace, cx: &AppContext) -> bool {
|
|
workspace.active_modal::<SshConnectionModal>(cx).is_some()
|
|
}
|
|
|
|
pub fn connect_over_ssh(
|
|
unique_identifier: String,
|
|
connection_options: SshConnectionOptions,
|
|
ui: View<SshPrompt>,
|
|
cx: &mut WindowContext,
|
|
) -> Task<Result<Option<Model<SshRemoteClient>>>> {
|
|
let window = cx.window_handle();
|
|
let known_password = connection_options.password.clone();
|
|
let (tx, rx) = oneshot::channel();
|
|
ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx));
|
|
|
|
remote::SshRemoteClient::new(
|
|
unique_identifier,
|
|
connection_options,
|
|
rx,
|
|
Arc::new(SshClientDelegate {
|
|
window,
|
|
ui: ui.downgrade(),
|
|
known_password,
|
|
}),
|
|
cx,
|
|
)
|
|
}
|
|
|
|
pub async fn open_ssh_project(
|
|
connection_options: SshConnectionOptions,
|
|
paths: Vec<PathBuf>,
|
|
app_state: Arc<AppState>,
|
|
open_options: workspace::OpenOptions,
|
|
cx: &mut AsyncAppContext,
|
|
) -> Result<()> {
|
|
let window = if let Some(window) = open_options.replace_window {
|
|
window
|
|
} else {
|
|
let options = cx.update(|cx| (app_state.build_window_options)(None, cx))?;
|
|
cx.open_window(options, |cx| {
|
|
let project = project::Project::local(
|
|
app_state.client.clone(),
|
|
app_state.node_runtime.clone(),
|
|
app_state.user_store.clone(),
|
|
app_state.languages.clone(),
|
|
app_state.fs.clone(),
|
|
None,
|
|
cx,
|
|
);
|
|
cx.new_view(|cx| Workspace::new(None, project, app_state.clone(), cx))
|
|
})?
|
|
};
|
|
|
|
loop {
|
|
let (cancel_tx, cancel_rx) = oneshot::channel();
|
|
let delegate = window.update(cx, {
|
|
let connection_options = connection_options.clone();
|
|
let paths = paths.clone();
|
|
move |workspace, cx| {
|
|
cx.activate_window();
|
|
workspace.toggle_modal(cx, |cx| {
|
|
SshConnectionModal::new(&connection_options, paths, cx)
|
|
});
|
|
|
|
let ui = workspace
|
|
.active_modal::<SshConnectionModal>(cx)?
|
|
.read(cx)
|
|
.prompt
|
|
.clone();
|
|
|
|
ui.update(cx, |ui, _cx| {
|
|
ui.set_cancellation_tx(cancel_tx);
|
|
});
|
|
|
|
Some(Arc::new(SshClientDelegate {
|
|
window: cx.window_handle(),
|
|
ui: ui.downgrade(),
|
|
known_password: connection_options.password.clone(),
|
|
}))
|
|
}
|
|
})?;
|
|
|
|
let Some(delegate) = delegate else { break };
|
|
|
|
let did_open_ssh_project = cx
|
|
.update(|cx| {
|
|
workspace::open_ssh_project(
|
|
window,
|
|
connection_options.clone(),
|
|
cancel_rx,
|
|
delegate.clone(),
|
|
app_state.clone(),
|
|
paths.clone(),
|
|
cx,
|
|
)
|
|
})?
|
|
.await;
|
|
|
|
window
|
|
.update(cx, |workspace, cx| {
|
|
if let Some(ui) = workspace.active_modal::<SshConnectionModal>(cx) {
|
|
ui.update(cx, |modal, cx| modal.finished(cx))
|
|
}
|
|
})
|
|
.ok();
|
|
|
|
if let Err(e) = did_open_ssh_project {
|
|
log::error!("Failed to open project: {:?}", e);
|
|
let response = window
|
|
.update(cx, |_, cx| {
|
|
cx.prompt(
|
|
PromptLevel::Critical,
|
|
"Failed to connect over SSH",
|
|
Some(&e.to_string()),
|
|
&["Retry", "Ok"],
|
|
)
|
|
})?
|
|
.await;
|
|
|
|
if response == Ok(0) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
// Already showed the error to the user
|
|
Ok(())
|
|
}
|