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 is_wsl: bool,
196 status_message: Option<SharedString>,
197 prompt: Option<(Entity<Markdown>, oneshot::Sender<String>)>,
198 cancellation: Option<oneshot::Sender<()>>,
199 editor: Entity<Editor>,
200}
201
202impl Drop for RemoteConnectionPrompt {
203 fn drop(&mut self) {
204 if let Some(cancel) = self.cancellation.take() {
205 cancel.send(()).ok();
206 }
207 }
208}
209
210pub struct RemoteConnectionModal {
211 pub(crate) prompt: Entity<RemoteConnectionPrompt>,
212 paths: Vec<PathBuf>,
213 finished: bool,
214}
215
216impl RemoteConnectionPrompt {
217 pub(crate) fn new(
218 connection_string: String,
219 nickname: Option<String>,
220 is_wsl: bool,
221 window: &mut Window,
222 cx: &mut Context<Self>,
223 ) -> Self {
224 Self {
225 connection_string: connection_string.into(),
226 nickname: nickname.map(|nickname| nickname.into()),
227 is_wsl,
228 editor: cx.new(|cx| Editor::single_line(window, cx)),
229 status_message: None,
230 cancellation: None,
231 prompt: None,
232 }
233 }
234
235 pub fn set_cancellation_tx(&mut self, tx: oneshot::Sender<()>) {
236 self.cancellation = Some(tx);
237 }
238
239 pub fn set_prompt(
240 &mut self,
241 prompt: String,
242 tx: oneshot::Sender<String>,
243 window: &mut Window,
244 cx: &mut Context<Self>,
245 ) {
246 let theme = ThemeSettings::get_global(cx);
247
248 let refinement = TextStyleRefinement {
249 font_family: Some(theme.buffer_font.family.clone()),
250 font_features: Some(FontFeatures::disable_ligatures()),
251 font_size: Some(theme.buffer_font_size(cx).into()),
252 color: Some(cx.theme().colors().editor_foreground),
253 background_color: Some(gpui::transparent_black()),
254 ..Default::default()
255 };
256
257 self.editor.update(cx, |editor, cx| {
258 if prompt.contains("yes/no") {
259 editor.set_masked(false, cx);
260 } else {
261 editor.set_masked(true, cx);
262 }
263 editor.set_text_style_refinement(refinement);
264 editor.set_cursor_shape(CursorShape::Block, cx);
265 });
266
267 let markdown = cx.new(|cx| Markdown::new_text(prompt.into(), cx));
268 self.prompt = Some((markdown, tx));
269 self.status_message.take();
270 window.focus(&self.editor.focus_handle(cx));
271 cx.notify();
272 }
273
274 pub fn set_status(&mut self, status: Option<String>, cx: &mut Context<Self>) {
275 self.status_message = status.map(|s| s.into());
276 cx.notify();
277 }
278
279 pub fn confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
280 if let Some((_, tx)) = self.prompt.take() {
281 self.status_message = Some("Connecting".into());
282 self.editor.update(cx, |editor, cx| {
283 tx.send(editor.text(cx)).ok();
284 editor.clear(window, cx);
285 });
286 }
287 }
288}
289
290impl Render for RemoteConnectionPrompt {
291 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
292 let theme = ThemeSettings::get_global(cx);
293
294 let mut text_style = window.text_style();
295 let refinement = TextStyleRefinement {
296 font_family: Some(theme.buffer_font.family.clone()),
297 font_features: Some(FontFeatures::disable_ligatures()),
298 font_size: Some(theme.buffer_font_size(cx).into()),
299 color: Some(cx.theme().colors().editor_foreground),
300 background_color: Some(gpui::transparent_black()),
301 ..Default::default()
302 };
303
304 text_style.refine(&refinement);
305 let markdown_style = MarkdownStyle {
306 base_text_style: text_style,
307 selection_background_color: cx.theme().colors().element_selection_background,
308 ..Default::default()
309 };
310
311 v_flex()
312 .key_context("PasswordPrompt")
313 .py_2()
314 .px_3()
315 .size_full()
316 .text_buffer(cx)
317 .when_some(self.status_message.clone(), |el, status_message| {
318 el.child(
319 h_flex()
320 .gap_1()
321 .child(
322 Icon::new(IconName::ArrowCircle)
323 .size(IconSize::Medium)
324 .with_rotate_animation(2),
325 )
326 .child(
327 div()
328 .text_ellipsis()
329 .overflow_x_hidden()
330 .child(format!("{}…", status_message)),
331 ),
332 )
333 })
334 .when_some(self.prompt.as_ref(), |el, prompt| {
335 el.child(
336 div()
337 .size_full()
338 .overflow_hidden()
339 .child(MarkdownElement::new(prompt.0.clone(), markdown_style))
340 .child(self.editor.clone()),
341 )
342 .when(window.capslock().on, |el| {
343 el.child(Label::new("⚠️ ⇪ is on"))
344 })
345 })
346 }
347}
348
349impl RemoteConnectionModal {
350 pub(crate) fn new(
351 connection_options: &RemoteConnectionOptions,
352 paths: Vec<PathBuf>,
353 window: &mut Window,
354 cx: &mut Context<Self>,
355 ) -> Self {
356 let (connection_string, nickname, is_wsl) = match connection_options {
357 RemoteConnectionOptions::Ssh(options) => {
358 (options.connection_string(), options.nickname.clone(), false)
359 }
360 RemoteConnectionOptions::Wsl(options) => (options.distro_name.clone(), None, true),
361 };
362 Self {
363 prompt: cx.new(|cx| {
364 RemoteConnectionPrompt::new(connection_string, nickname, is_wsl, window, cx)
365 }),
366 finished: false,
367 paths,
368 }
369 }
370
371 fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
372 self.prompt
373 .update(cx, |prompt, cx| prompt.confirm(window, cx))
374 }
375
376 pub fn finished(&mut self, cx: &mut Context<Self>) {
377 self.finished = true;
378 cx.emit(DismissEvent);
379 }
380
381 fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
382 if let Some(tx) = self
383 .prompt
384 .update(cx, |prompt, _cx| prompt.cancellation.take())
385 {
386 tx.send(()).ok();
387 }
388 self.finished(cx);
389 }
390}
391
392pub(crate) struct SshConnectionHeader {
393 pub(crate) connection_string: SharedString,
394 pub(crate) paths: Vec<PathBuf>,
395 pub(crate) nickname: Option<SharedString>,
396 pub(crate) is_wsl: bool,
397}
398
399impl RenderOnce for SshConnectionHeader {
400 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
401 let theme = cx.theme();
402
403 let mut header_color = theme.colors().text;
404 header_color.fade_out(0.96);
405
406 let (main_label, meta_label) = if let Some(nickname) = self.nickname {
407 (nickname, Some(format!("({})", self.connection_string)))
408 } else {
409 (self.connection_string, None)
410 };
411
412 let icon = match self.is_wsl {
413 true => IconName::Linux,
414 false => IconName::Server,
415 };
416
417 h_flex()
418 .px(DynamicSpacing::Base12.rems(cx))
419 .pt(DynamicSpacing::Base08.rems(cx))
420 .pb(DynamicSpacing::Base04.rems(cx))
421 .rounded_t_sm()
422 .w_full()
423 .gap_1p5()
424 .child(Icon::new(icon).size(IconSize::Small))
425 .child(
426 h_flex()
427 .gap_1()
428 .overflow_x_hidden()
429 .child(
430 div()
431 .max_w_96()
432 .overflow_x_hidden()
433 .text_ellipsis()
434 .child(Headline::new(main_label).size(HeadlineSize::XSmall)),
435 )
436 .children(
437 meta_label.map(|label| {
438 Label::new(label).color(Color::Muted).size(LabelSize::Small)
439 }),
440 )
441 .child(div().overflow_x_hidden().text_ellipsis().children(
442 self.paths.into_iter().map(|path| {
443 Label::new(path.to_string_lossy().to_string())
444 .size(LabelSize::Small)
445 .color(Color::Muted)
446 }),
447 )),
448 )
449 }
450}
451
452impl Render for RemoteConnectionModal {
453 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
454 let nickname = self.prompt.read(cx).nickname.clone();
455 let connection_string = self.prompt.read(cx).connection_string.clone();
456 let is_wsl = self.prompt.read(cx).is_wsl;
457
458 let theme = cx.theme().clone();
459 let body_color = theme.colors().editor_background;
460
461 v_flex()
462 .elevation_3(cx)
463 .w(rems(34.))
464 .border_1()
465 .border_color(theme.colors().border)
466 .key_context("SshConnectionModal")
467 .track_focus(&self.focus_handle(cx))
468 .on_action(cx.listener(Self::dismiss))
469 .on_action(cx.listener(Self::confirm))
470 .child(
471 SshConnectionHeader {
472 paths: self.paths.clone(),
473 connection_string,
474 nickname,
475 is_wsl,
476 }
477 .render(window, cx),
478 )
479 .child(
480 div()
481 .w_full()
482 .rounded_b_lg()
483 .bg(body_color)
484 .border_t_1()
485 .border_color(theme.colors().border_variant)
486 .child(self.prompt.clone()),
487 )
488 }
489}
490
491impl Focusable for RemoteConnectionModal {
492 fn focus_handle(&self, cx: &gpui::App) -> gpui::FocusHandle {
493 self.prompt.read(cx).editor.focus_handle(cx)
494 }
495}
496
497impl EventEmitter<DismissEvent> for RemoteConnectionModal {}
498
499impl ModalView for RemoteConnectionModal {
500 fn on_before_dismiss(
501 &mut self,
502 _window: &mut Window,
503 _: &mut Context<Self>,
504 ) -> workspace::DismissDecision {
505 workspace::DismissDecision::Dismiss(self.finished)
506 }
507
508 fn fade_out_background(&self) -> bool {
509 true
510 }
511}
512
513#[derive(Clone)]
514pub struct RemoteClientDelegate {
515 window: AnyWindowHandle,
516 ui: WeakEntity<RemoteConnectionPrompt>,
517 known_password: Option<String>,
518}
519
520impl remote::RemoteClientDelegate for RemoteClientDelegate {
521 fn ask_password(&self, prompt: String, tx: oneshot::Sender<String>, cx: &mut AsyncApp) {
522 let mut known_password = self.known_password.clone();
523 if let Some(password) = known_password.take() {
524 tx.send(password).ok();
525 } else {
526 self.window
527 .update(cx, |_, window, cx| {
528 self.ui.update(cx, |modal, cx| {
529 modal.set_prompt(prompt, tx, window, cx);
530 })
531 })
532 .ok();
533 }
534 }
535
536 fn set_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
537 self.update_status(status, cx)
538 }
539
540 fn download_server_binary_locally(
541 &self,
542 platform: RemotePlatform,
543 release_channel: ReleaseChannel,
544 version: Option<SemanticVersion>,
545 cx: &mut AsyncApp,
546 ) -> Task<anyhow::Result<PathBuf>> {
547 cx.spawn(async move |cx| {
548 let binary_path = AutoUpdater::download_remote_server_release(
549 platform.os,
550 platform.arch,
551 release_channel,
552 version,
553 cx,
554 )
555 .await
556 .with_context(|| {
557 format!(
558 "Downloading remote server binary (version: {}, os: {}, arch: {})",
559 version
560 .map(|v| format!("{}", v))
561 .unwrap_or("unknown".to_string()),
562 platform.os,
563 platform.arch,
564 )
565 })?;
566 Ok(binary_path)
567 })
568 }
569
570 fn get_download_params(
571 &self,
572 platform: RemotePlatform,
573 release_channel: ReleaseChannel,
574 version: Option<SemanticVersion>,
575 cx: &mut AsyncApp,
576 ) -> Task<Result<Option<(String, String)>>> {
577 cx.spawn(async move |cx| {
578 AutoUpdater::get_remote_server_release_url(
579 platform.os,
580 platform.arch,
581 release_channel,
582 version,
583 cx,
584 )
585 .await
586 })
587 }
588}
589
590impl RemoteClientDelegate {
591 fn update_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
592 self.window
593 .update(cx, |_, _, cx| {
594 self.ui.update(cx, |modal, cx| {
595 modal.set_status(status.map(|s| s.to_string()), cx);
596 })
597 })
598 .ok();
599 }
600}
601
602pub fn connect_over_ssh(
603 unique_identifier: ConnectionIdentifier,
604 connection_options: SshConnectionOptions,
605 ui: Entity<RemoteConnectionPrompt>,
606 window: &mut Window,
607 cx: &mut App,
608) -> Task<Result<Option<Entity<RemoteClient>>>> {
609 let window = window.window_handle();
610 let known_password = connection_options.password.clone();
611 let (tx, rx) = oneshot::channel();
612 ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx));
613
614 remote::RemoteClient::ssh(
615 unique_identifier,
616 connection_options,
617 rx,
618 Arc::new(RemoteClientDelegate {
619 window,
620 ui: ui.downgrade(),
621 known_password,
622 }),
623 cx,
624 )
625}
626
627pub fn connect(
628 unique_identifier: ConnectionIdentifier,
629 connection_options: RemoteConnectionOptions,
630 ui: Entity<RemoteConnectionPrompt>,
631 window: &mut Window,
632 cx: &mut App,
633) -> Task<Result<Option<Entity<RemoteClient>>>> {
634 let window = window.window_handle();
635 let known_password = match &connection_options {
636 RemoteConnectionOptions::Ssh(ssh_connection_options) => {
637 ssh_connection_options.password.clone()
638 }
639 _ => None,
640 };
641 let (tx, rx) = oneshot::channel();
642 ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx));
643
644 remote::RemoteClient::new(
645 unique_identifier,
646 connection_options,
647 rx,
648 Arc::new(RemoteClientDelegate {
649 window,
650 ui: ui.downgrade(),
651 known_password,
652 }),
653 cx,
654 )
655}
656
657pub async fn open_remote_project(
658 connection_options: RemoteConnectionOptions,
659 paths: Vec<PathBuf>,
660 app_state: Arc<AppState>,
661 open_options: workspace::OpenOptions,
662 cx: &mut AsyncApp,
663) -> Result<()> {
664 let window = if let Some(window) = open_options.replace_window {
665 window
666 } else {
667 let workspace_position = cx
668 .update(|cx| {
669 workspace::remote_workspace_position_from_db(connection_options.clone(), &paths, cx)
670 })?
671 .await
672 .context("fetching ssh workspace position from db")?;
673
674 let mut options =
675 cx.update(|cx| (app_state.build_window_options)(workspace_position.display, cx))?;
676 options.window_bounds = workspace_position.window_bounds;
677
678 cx.open_window(options, |window, cx| {
679 let project = project::Project::local(
680 app_state.client.clone(),
681 app_state.node_runtime.clone(),
682 app_state.user_store.clone(),
683 app_state.languages.clone(),
684 app_state.fs.clone(),
685 None,
686 cx,
687 );
688 cx.new(|cx| {
689 let mut workspace = Workspace::new(None, project, app_state.clone(), window, cx);
690 workspace.centered_layout = workspace_position.centered_layout;
691 workspace
692 })
693 })?
694 };
695
696 loop {
697 let (cancel_tx, cancel_rx) = oneshot::channel();
698 let delegate = window.update(cx, {
699 let paths = paths.clone();
700 let connection_options = connection_options.clone();
701 move |workspace, window, cx| {
702 window.activate_window();
703 workspace.toggle_modal(window, cx, |window, cx| {
704 RemoteConnectionModal::new(&connection_options, paths, window, cx)
705 });
706
707 let ui = workspace
708 .active_modal::<RemoteConnectionModal>(cx)?
709 .read(cx)
710 .prompt
711 .clone();
712
713 ui.update(cx, |ui, _cx| {
714 ui.set_cancellation_tx(cancel_tx);
715 });
716
717 Some(Arc::new(RemoteClientDelegate {
718 window: window.window_handle(),
719 ui: ui.downgrade(),
720 known_password: if let RemoteConnectionOptions::Ssh(options) =
721 &connection_options
722 {
723 options.password.clone()
724 } else {
725 None
726 },
727 }))
728 }
729 })?;
730
731 let Some(delegate) = delegate else { break };
732
733 let did_open_project = cx
734 .update(|cx| {
735 workspace::open_remote_project_with_new_connection(
736 window,
737 connection_options.clone(),
738 cancel_rx,
739 delegate.clone(),
740 app_state.clone(),
741 paths.clone(),
742 cx,
743 )
744 })?
745 .await;
746
747 window
748 .update(cx, |workspace, _, cx| {
749 if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
750 ui.update(cx, |modal, cx| modal.finished(cx))
751 }
752 })
753 .ok();
754
755 if let Err(e) = did_open_project {
756 log::error!("Failed to open project: {e:?}");
757 let response = window
758 .update(cx, |_, window, cx| {
759 window.prompt(
760 PromptLevel::Critical,
761 match connection_options {
762 RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
763 RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
764 },
765 Some(&e.to_string()),
766 &["Retry", "Ok"],
767 cx,
768 )
769 })?
770 .await;
771
772 if response == Ok(0) {
773 continue;
774 }
775 }
776
777 window
778 .update(cx, |workspace, _, cx| {
779 if let Some(client) = workspace.project().read(cx).remote_client() {
780 ExtensionStore::global(cx)
781 .update(cx, |store, cx| store.register_remote_client(client, cx));
782 }
783 })
784 .ok();
785
786 break;
787 }
788
789 // Already showed the error to the user
790 Ok(())
791}