1use std::collections::BTreeSet;
2use std::{path::PathBuf, sync::Arc};
3
4use anyhow::{Context as _, Result};
5use auto_update::AutoUpdater;
6use editor::Editor;
7use extension_host::ExtensionStore;
8use futures::channel::oneshot;
9use gpui::{
10 AnyWindowHandle, App, AsyncApp, DismissEvent, Entity, EventEmitter, Focusable, FontFeatures,
11 ParentElement as _, PromptLevel, Render, SemanticVersion, SharedString, Task,
12 TextStyleRefinement, WeakEntity,
13};
14
15use language::CursorShape;
16use markdown::{Markdown, MarkdownElement, MarkdownStyle};
17use release_channel::ReleaseChannel;
18use remote::{
19 ConnectionIdentifier, RemoteClient, RemoteConnectionOptions, RemotePlatform,
20 SshConnectionOptions, SshPortForwardOption, WslConnectionOptions,
21};
22use schemars::JsonSchema;
23use serde::{Deserialize, Serialize};
24use settings::{Settings, SettingsKey, SettingsSources, SettingsUi};
25use theme::ThemeSettings;
26use ui::{
27 ActiveTheme, Color, CommonAnimationExt, Context, Icon, IconName, IconSize, InteractiveElement,
28 IntoElement, Label, LabelCommon, Styled, Window, prelude::*,
29};
30use util::serde::default_true;
31use workspace::{AppState, ModalView, Workspace};
32
33#[derive(Deserialize)]
34pub struct SshSettings {
35 pub ssh_connections: Option<Vec<SshConnection>>,
36 pub wsl_connections: Option<Vec<WslConnection>>,
37 /// Whether to read ~/.ssh/config for ssh connection sources.
38 #[serde(default = "default_true")]
39 pub read_ssh_config: bool,
40}
41
42impl SshSettings {
43 pub fn ssh_connections(&self) -> impl Iterator<Item = SshConnection> + use<> {
44 self.ssh_connections.clone().into_iter().flatten()
45 }
46
47 pub fn wsl_connections(&self) -> impl Iterator<Item = WslConnection> + use<> {
48 self.wsl_connections.clone().into_iter().flatten()
49 }
50
51 pub fn fill_connection_options_from_settings(&self, options: &mut SshConnectionOptions) {
52 for conn in self.ssh_connections() {
53 if conn.host == options.host
54 && conn.username == options.username
55 && conn.port == options.port
56 {
57 options.nickname = conn.nickname;
58 options.upload_binary_over_ssh = conn.upload_binary_over_ssh.unwrap_or_default();
59 options.args = Some(conn.args);
60 options.port_forwards = conn.port_forwards;
61 break;
62 }
63 }
64 }
65
66 pub fn connection_options_for(
67 &self,
68 host: String,
69 port: Option<u16>,
70 username: Option<String>,
71 ) -> SshConnectionOptions {
72 let mut options = SshConnectionOptions {
73 host,
74 port,
75 username,
76 ..Default::default()
77 };
78 self.fill_connection_options_from_settings(&mut options);
79 options
80 }
81}
82
83#[derive(Clone, Default, Serialize, Deserialize, PartialEq, JsonSchema)]
84pub struct SshConnection {
85 pub host: SharedString,
86 #[serde(skip_serializing_if = "Option::is_none")]
87 pub username: Option<String>,
88 #[serde(skip_serializing_if = "Option::is_none")]
89 pub port: Option<u16>,
90 #[serde(skip_serializing_if = "Vec::is_empty")]
91 #[serde(default)]
92 pub args: Vec<String>,
93 #[serde(default)]
94 pub projects: BTreeSet<SshProject>,
95 /// Name to use for this server in UI.
96 #[serde(skip_serializing_if = "Option::is_none")]
97 pub nickname: Option<String>,
98 // By default Zed will download the binary to the host directly.
99 // If this is set to true, Zed will download the binary to your local machine,
100 // and then upload it over the SSH connection. Useful if your SSH server has
101 // limited outbound internet access.
102 #[serde(skip_serializing_if = "Option::is_none")]
103 pub upload_binary_over_ssh: Option<bool>,
104
105 #[serde(skip_serializing_if = "Option::is_none")]
106 pub port_forwards: Option<Vec<SshPortForwardOption>>,
107}
108
109impl From<SshConnection> for SshConnectionOptions {
110 fn from(val: SshConnection) -> Self {
111 SshConnectionOptions {
112 host: val.host.into(),
113 username: val.username,
114 port: val.port,
115 password: None,
116 args: Some(val.args),
117 nickname: val.nickname,
118 upload_binary_over_ssh: val.upload_binary_over_ssh.unwrap_or_default(),
119 port_forwards: val.port_forwards,
120 }
121 }
122}
123
124#[derive(Clone, Default, Serialize, Deserialize, PartialEq, JsonSchema)]
125pub struct WslConnection {
126 pub distro_name: SharedString,
127 #[serde(default)]
128 pub user: Option<String>,
129 #[serde(default)]
130 pub projects: BTreeSet<SshProject>,
131}
132
133impl From<WslConnection> for WslConnectionOptions {
134 fn from(val: WslConnection) -> Self {
135 WslConnectionOptions {
136 distro_name: val.distro_name.into(),
137 user: val.user,
138 }
139 }
140}
141
142#[derive(Clone, Serialize, Deserialize, PartialEq, JsonSchema)]
143pub enum Connection {
144 Ssh(SshConnection),
145 Wsl(WslConnection),
146}
147
148impl From<Connection> for RemoteConnectionOptions {
149 fn from(val: Connection) -> Self {
150 match val {
151 Connection::Ssh(conn) => RemoteConnectionOptions::Ssh(conn.into()),
152 Connection::Wsl(conn) => RemoteConnectionOptions::Wsl(conn.into()),
153 }
154 }
155}
156
157impl From<SshConnection> for Connection {
158 fn from(val: SshConnection) -> Self {
159 Connection::Ssh(val)
160 }
161}
162
163impl From<WslConnection> for Connection {
164 fn from(val: WslConnection) -> Self {
165 Connection::Wsl(val)
166 }
167}
168
169#[derive(Clone, Default, Serialize, PartialEq, Eq, PartialOrd, Ord, Deserialize, JsonSchema)]
170pub struct SshProject {
171 pub paths: Vec<String>,
172}
173
174#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, SettingsUi, SettingsKey)]
175#[settings_key(None)]
176pub struct RemoteSettingsContent {
177 pub ssh_connections: Option<Vec<SshConnection>>,
178 pub wsl_connections: Option<Vec<WslConnection>>,
179 pub read_ssh_config: Option<bool>,
180}
181
182impl Settings for SshSettings {
183 type FileContent = RemoteSettingsContent;
184
185 fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> Result<Self> {
186 sources.json_merge()
187 }
188
189 fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {}
190}
191
192pub struct RemoteConnectionPrompt {
193 connection_string: SharedString,
194 nickname: Option<SharedString>,
195 status_message: Option<SharedString>,
196 prompt: Option<(Entity<Markdown>, oneshot::Sender<String>)>,
197 cancellation: Option<oneshot::Sender<()>>,
198 editor: Entity<Editor>,
199}
200
201impl Drop for RemoteConnectionPrompt {
202 fn drop(&mut self) {
203 if let Some(cancel) = self.cancellation.take() {
204 cancel.send(()).ok();
205 }
206 }
207}
208
209pub struct RemoteConnectionModal {
210 pub(crate) prompt: Entity<RemoteConnectionPrompt>,
211 paths: Vec<PathBuf>,
212 finished: bool,
213}
214
215impl RemoteConnectionPrompt {
216 pub(crate) fn new(
217 connection_string: String,
218 nickname: Option<String>,
219 window: &mut Window,
220 cx: &mut Context<Self>,
221 ) -> Self {
222 Self {
223 connection_string: connection_string.into(),
224 nickname: nickname.map(|nickname| nickname.into()),
225 editor: cx.new(|cx| Editor::single_line(window, cx)),
226 status_message: None,
227 cancellation: None,
228 prompt: None,
229 }
230 }
231
232 pub fn set_cancellation_tx(&mut self, tx: oneshot::Sender<()>) {
233 self.cancellation = Some(tx);
234 }
235
236 pub fn set_prompt(
237 &mut self,
238 prompt: String,
239 tx: oneshot::Sender<String>,
240 window: &mut Window,
241 cx: &mut Context<Self>,
242 ) {
243 let theme = ThemeSettings::get_global(cx);
244
245 let refinement = TextStyleRefinement {
246 font_family: Some(theme.buffer_font.family.clone()),
247 font_features: Some(FontFeatures::disable_ligatures()),
248 font_size: Some(theme.buffer_font_size(cx).into()),
249 color: Some(cx.theme().colors().editor_foreground),
250 background_color: Some(gpui::transparent_black()),
251 ..Default::default()
252 };
253
254 self.editor.update(cx, |editor, cx| {
255 if prompt.contains("yes/no") {
256 editor.set_masked(false, cx);
257 } else {
258 editor.set_masked(true, cx);
259 }
260 editor.set_text_style_refinement(refinement);
261 editor.set_cursor_shape(CursorShape::Block, cx);
262 });
263
264 let markdown = cx.new(|cx| Markdown::new_text(prompt.into(), cx));
265 self.prompt = Some((markdown, tx));
266 self.status_message.take();
267 window.focus(&self.editor.focus_handle(cx));
268 cx.notify();
269 }
270
271 pub fn set_status(&mut self, status: Option<String>, cx: &mut Context<Self>) {
272 self.status_message = status.map(|s| s.into());
273 cx.notify();
274 }
275
276 pub fn confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
277 if let Some((_, tx)) = self.prompt.take() {
278 self.status_message = Some("Connecting".into());
279 self.editor.update(cx, |editor, cx| {
280 tx.send(editor.text(cx)).ok();
281 editor.clear(window, cx);
282 });
283 }
284 }
285}
286
287impl Render for RemoteConnectionPrompt {
288 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
289 let theme = ThemeSettings::get_global(cx);
290
291 let mut text_style = window.text_style();
292 let refinement = TextStyleRefinement {
293 font_family: Some(theme.buffer_font.family.clone()),
294 font_features: Some(FontFeatures::disable_ligatures()),
295 font_size: Some(theme.buffer_font_size(cx).into()),
296 color: Some(cx.theme().colors().editor_foreground),
297 background_color: Some(gpui::transparent_black()),
298 ..Default::default()
299 };
300
301 text_style.refine(&refinement);
302 let markdown_style = MarkdownStyle {
303 base_text_style: text_style,
304 selection_background_color: cx.theme().colors().element_selection_background,
305 ..Default::default()
306 };
307
308 v_flex()
309 .key_context("PasswordPrompt")
310 .py_2()
311 .px_3()
312 .size_full()
313 .text_buffer(cx)
314 .when_some(self.status_message.clone(), |el, status_message| {
315 el.child(
316 h_flex()
317 .gap_1()
318 .child(
319 Icon::new(IconName::ArrowCircle)
320 .size(IconSize::Medium)
321 .with_rotate_animation(2),
322 )
323 .child(
324 div()
325 .text_ellipsis()
326 .overflow_x_hidden()
327 .child(format!("{}…", status_message)),
328 ),
329 )
330 })
331 .when_some(self.prompt.as_ref(), |el, prompt| {
332 el.child(
333 div()
334 .size_full()
335 .overflow_hidden()
336 .child(MarkdownElement::new(prompt.0.clone(), markdown_style))
337 .child(self.editor.clone()),
338 )
339 .when(window.capslock().on, |el| {
340 el.child(Label::new("⚠️ ⇪ is on"))
341 })
342 })
343 }
344}
345
346impl RemoteConnectionModal {
347 pub(crate) fn new(
348 connection_options: &RemoteConnectionOptions,
349 paths: Vec<PathBuf>,
350 window: &mut Window,
351 cx: &mut Context<Self>,
352 ) -> Self {
353 let (connection_string, nickname) = match connection_options {
354 RemoteConnectionOptions::Ssh(options) => {
355 (options.connection_string(), options.nickname.clone())
356 }
357 RemoteConnectionOptions::Wsl(options) => (options.distro_name.clone(), None),
358 };
359 Self {
360 prompt: cx
361 .new(|cx| RemoteConnectionPrompt::new(connection_string, nickname, window, cx)),
362 finished: false,
363 paths,
364 }
365 }
366
367 fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
368 self.prompt
369 .update(cx, |prompt, cx| prompt.confirm(window, cx))
370 }
371
372 pub fn finished(&mut self, cx: &mut Context<Self>) {
373 self.finished = true;
374 cx.emit(DismissEvent);
375 }
376
377 fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
378 if let Some(tx) = self
379 .prompt
380 .update(cx, |prompt, _cx| prompt.cancellation.take())
381 {
382 tx.send(()).ok();
383 }
384 self.finished(cx);
385 }
386}
387
388pub(crate) struct SshConnectionHeader {
389 pub(crate) connection_string: SharedString,
390 pub(crate) paths: Vec<PathBuf>,
391 pub(crate) nickname: Option<SharedString>,
392}
393
394impl RenderOnce for SshConnectionHeader {
395 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
396 let theme = cx.theme();
397
398 let mut header_color = theme.colors().text;
399 header_color.fade_out(0.96);
400
401 let (main_label, meta_label) = if let Some(nickname) = self.nickname {
402 (nickname, Some(format!("({})", self.connection_string)))
403 } else {
404 (self.connection_string, None)
405 };
406
407 h_flex()
408 .px(DynamicSpacing::Base12.rems(cx))
409 .pt(DynamicSpacing::Base08.rems(cx))
410 .pb(DynamicSpacing::Base04.rems(cx))
411 .rounded_t_sm()
412 .w_full()
413 .gap_1p5()
414 .child(Icon::new(IconName::Server).size(IconSize::Small))
415 .child(
416 h_flex()
417 .gap_1()
418 .overflow_x_hidden()
419 .child(
420 div()
421 .max_w_96()
422 .overflow_x_hidden()
423 .text_ellipsis()
424 .child(Headline::new(main_label).size(HeadlineSize::XSmall)),
425 )
426 .children(
427 meta_label.map(|label| {
428 Label::new(label).color(Color::Muted).size(LabelSize::Small)
429 }),
430 )
431 .child(div().overflow_x_hidden().text_ellipsis().children(
432 self.paths.into_iter().map(|path| {
433 Label::new(path.to_string_lossy().to_string())
434 .size(LabelSize::Small)
435 .color(Color::Muted)
436 }),
437 )),
438 )
439 }
440}
441
442impl Render for RemoteConnectionModal {
443 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
444 let nickname = self.prompt.read(cx).nickname.clone();
445 let connection_string = self.prompt.read(cx).connection_string.clone();
446
447 let theme = cx.theme().clone();
448 let body_color = theme.colors().editor_background;
449
450 v_flex()
451 .elevation_3(cx)
452 .w(rems(34.))
453 .border_1()
454 .border_color(theme.colors().border)
455 .key_context("SshConnectionModal")
456 .track_focus(&self.focus_handle(cx))
457 .on_action(cx.listener(Self::dismiss))
458 .on_action(cx.listener(Self::confirm))
459 .child(
460 SshConnectionHeader {
461 paths: self.paths.clone(),
462 connection_string,
463 nickname,
464 }
465 .render(window, cx),
466 )
467 .child(
468 div()
469 .w_full()
470 .rounded_b_lg()
471 .bg(body_color)
472 .border_t_1()
473 .border_color(theme.colors().border_variant)
474 .child(self.prompt.clone()),
475 )
476 }
477}
478
479impl Focusable for RemoteConnectionModal {
480 fn focus_handle(&self, cx: &gpui::App) -> gpui::FocusHandle {
481 self.prompt.read(cx).editor.focus_handle(cx)
482 }
483}
484
485impl EventEmitter<DismissEvent> for RemoteConnectionModal {}
486
487impl ModalView for RemoteConnectionModal {
488 fn on_before_dismiss(
489 &mut self,
490 _window: &mut Window,
491 _: &mut Context<Self>,
492 ) -> workspace::DismissDecision {
493 workspace::DismissDecision::Dismiss(self.finished)
494 }
495
496 fn fade_out_background(&self) -> bool {
497 true
498 }
499}
500
501#[derive(Clone)]
502pub struct RemoteClientDelegate {
503 window: AnyWindowHandle,
504 ui: WeakEntity<RemoteConnectionPrompt>,
505 known_password: Option<String>,
506}
507
508impl remote::RemoteClientDelegate for RemoteClientDelegate {
509 fn ask_password(&self, prompt: String, tx: oneshot::Sender<String>, cx: &mut AsyncApp) {
510 let mut known_password = self.known_password.clone();
511 if let Some(password) = known_password.take() {
512 tx.send(password).ok();
513 } else {
514 self.window
515 .update(cx, |_, window, cx| {
516 self.ui.update(cx, |modal, cx| {
517 modal.set_prompt(prompt, tx, window, cx);
518 })
519 })
520 .ok();
521 }
522 }
523
524 fn set_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
525 self.update_status(status, cx)
526 }
527
528 fn download_server_binary_locally(
529 &self,
530 platform: RemotePlatform,
531 release_channel: ReleaseChannel,
532 version: Option<SemanticVersion>,
533 cx: &mut AsyncApp,
534 ) -> Task<anyhow::Result<PathBuf>> {
535 cx.spawn(async move |cx| {
536 let binary_path = AutoUpdater::download_remote_server_release(
537 platform.os,
538 platform.arch,
539 release_channel,
540 version,
541 cx,
542 )
543 .await
544 .with_context(|| {
545 format!(
546 "Downloading remote server binary (version: {}, os: {}, arch: {})",
547 version
548 .map(|v| format!("{}", v))
549 .unwrap_or("unknown".to_string()),
550 platform.os,
551 platform.arch,
552 )
553 })?;
554 Ok(binary_path)
555 })
556 }
557
558 fn get_download_params(
559 &self,
560 platform: RemotePlatform,
561 release_channel: ReleaseChannel,
562 version: Option<SemanticVersion>,
563 cx: &mut AsyncApp,
564 ) -> Task<Result<Option<(String, String)>>> {
565 cx.spawn(async move |cx| {
566 AutoUpdater::get_remote_server_release_url(
567 platform.os,
568 platform.arch,
569 release_channel,
570 version,
571 cx,
572 )
573 .await
574 })
575 }
576}
577
578impl RemoteClientDelegate {
579 fn update_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
580 self.window
581 .update(cx, |_, _, cx| {
582 self.ui.update(cx, |modal, cx| {
583 modal.set_status(status.map(|s| s.to_string()), cx);
584 })
585 })
586 .ok();
587 }
588}
589
590pub fn connect_over_ssh(
591 unique_identifier: ConnectionIdentifier,
592 connection_options: SshConnectionOptions,
593 ui: Entity<RemoteConnectionPrompt>,
594 window: &mut Window,
595 cx: &mut App,
596) -> Task<Result<Option<Entity<RemoteClient>>>> {
597 let window = window.window_handle();
598 let known_password = connection_options.password.clone();
599 let (tx, rx) = oneshot::channel();
600 ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx));
601
602 remote::RemoteClient::ssh(
603 unique_identifier,
604 connection_options,
605 rx,
606 Arc::new(RemoteClientDelegate {
607 window,
608 ui: ui.downgrade(),
609 known_password,
610 }),
611 cx,
612 )
613}
614
615pub fn connect(
616 unique_identifier: ConnectionIdentifier,
617 connection_options: RemoteConnectionOptions,
618 ui: Entity<RemoteConnectionPrompt>,
619 window: &mut Window,
620 cx: &mut App,
621) -> Task<Result<Option<Entity<RemoteClient>>>> {
622 let window = window.window_handle();
623 let known_password = match &connection_options {
624 RemoteConnectionOptions::Ssh(ssh_connection_options) => {
625 ssh_connection_options.password.clone()
626 }
627 _ => None,
628 };
629 let (tx, rx) = oneshot::channel();
630 ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx));
631
632 remote::RemoteClient::new(
633 unique_identifier,
634 connection_options,
635 rx,
636 Arc::new(RemoteClientDelegate {
637 window,
638 ui: ui.downgrade(),
639 known_password,
640 }),
641 cx,
642 )
643}
644
645pub async fn open_remote_project(
646 connection_options: RemoteConnectionOptions,
647 paths: Vec<PathBuf>,
648 app_state: Arc<AppState>,
649 open_options: workspace::OpenOptions,
650 cx: &mut AsyncApp,
651) -> Result<()> {
652 let window = if let Some(window) = open_options.replace_window {
653 window
654 } else {
655 let workspace_position = cx
656 .update(|cx| {
657 workspace::remote_workspace_position_from_db(connection_options.clone(), &paths, cx)
658 })?
659 .await
660 .context("fetching ssh workspace position from db")?;
661
662 let mut options =
663 cx.update(|cx| (app_state.build_window_options)(workspace_position.display, cx))?;
664 options.window_bounds = workspace_position.window_bounds;
665
666 cx.open_window(options, |window, cx| {
667 let project = project::Project::local(
668 app_state.client.clone(),
669 app_state.node_runtime.clone(),
670 app_state.user_store.clone(),
671 app_state.languages.clone(),
672 app_state.fs.clone(),
673 None,
674 cx,
675 );
676 cx.new(|cx| {
677 let mut workspace = Workspace::new(None, project, app_state.clone(), window, cx);
678 workspace.centered_layout = workspace_position.centered_layout;
679 workspace
680 })
681 })?
682 };
683
684 loop {
685 let (cancel_tx, cancel_rx) = oneshot::channel();
686 let delegate = window.update(cx, {
687 let paths = paths.clone();
688 let connection_options = connection_options.clone();
689 move |workspace, window, cx| {
690 window.activate_window();
691 workspace.toggle_modal(window, cx, |window, cx| {
692 RemoteConnectionModal::new(&connection_options, paths, window, cx)
693 });
694
695 let ui = workspace
696 .active_modal::<RemoteConnectionModal>(cx)?
697 .read(cx)
698 .prompt
699 .clone();
700
701 ui.update(cx, |ui, _cx| {
702 ui.set_cancellation_tx(cancel_tx);
703 });
704
705 Some(Arc::new(RemoteClientDelegate {
706 window: window.window_handle(),
707 ui: ui.downgrade(),
708 known_password: if let RemoteConnectionOptions::Ssh(options) =
709 &connection_options
710 {
711 options.password.clone()
712 } else {
713 None
714 },
715 }))
716 }
717 })?;
718
719 let Some(delegate) = delegate else { break };
720
721 let did_open_project = cx
722 .update(|cx| {
723 workspace::open_remote_project_with_new_connection(
724 window,
725 connection_options.clone(),
726 cancel_rx,
727 delegate.clone(),
728 app_state.clone(),
729 paths.clone(),
730 cx,
731 )
732 })?
733 .await;
734
735 window
736 .update(cx, |workspace, _, cx| {
737 if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
738 ui.update(cx, |modal, cx| modal.finished(cx))
739 }
740 })
741 .ok();
742
743 if let Err(e) = did_open_project {
744 log::error!("Failed to open project: {e:?}");
745 let response = window
746 .update(cx, |_, window, cx| {
747 window.prompt(
748 PromptLevel::Critical,
749 match connection_options {
750 RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
751 RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
752 },
753 Some(&e.to_string()),
754 &["Retry", "Ok"],
755 cx,
756 )
757 })?
758 .await;
759
760 if response == Ok(0) {
761 continue;
762 }
763 }
764
765 window
766 .update(cx, |workspace, _, cx| {
767 if let Some(client) = workspace.project().read(cx).remote_client() {
768 ExtensionStore::global(cx)
769 .update(cx, |store, cx| store.register_remote_client(client, cx));
770 }
771 })
772 .ok();
773
774 break;
775 }
776
777 // Already showed the error to the user
778 Ok(())
779}