1use std::{path::PathBuf, sync::Arc};
2
3use anyhow::Result;
4use askpass::EncryptedPassword;
5use auto_update::AutoUpdater;
6use futures::{FutureExt as _, channel::oneshot, select};
7use gpui::{
8 AnyWindowHandle, App, AsyncApp, DismissEvent, Entity, EventEmitter, Focusable, FontFeatures,
9 ParentElement as _, Render, SharedString, Task, TextStyleRefinement, WeakEntity,
10};
11use markdown::{Markdown, MarkdownElement, MarkdownStyle};
12use release_channel::ReleaseChannel;
13use remote::{ConnectionIdentifier, RemoteClient, RemoteConnectionOptions, RemotePlatform};
14use semver::Version;
15use settings::Settings;
16use theme_settings::ThemeSettings;
17use ui::{
18 ActiveTheme, CommonAnimationExt, Context, InteractiveElement, KeyBinding, ListItem, Tooltip,
19 prelude::*,
20};
21use ui_input::{ERASED_EDITOR_FACTORY, ErasedEditor};
22use workspace::{DismissDecision, ModalView, Workspace};
23
24pub struct RemoteConnectionPrompt {
25 connection_string: SharedString,
26 nickname: Option<SharedString>,
27 is_wsl: bool,
28 is_devcontainer: bool,
29 status_message: Option<SharedString>,
30 prompt: Option<(Entity<Markdown>, oneshot::Sender<EncryptedPassword>)>,
31 cancellation: Option<oneshot::Sender<()>>,
32 editor: Arc<dyn ErasedEditor>,
33 is_password_prompt: bool,
34 is_masked: bool,
35}
36
37impl Drop for RemoteConnectionPrompt {
38 fn drop(&mut self) {
39 if let Some(cancel) = self.cancellation.take() {
40 log::debug!("cancelling remote connection");
41 cancel.send(()).ok();
42 }
43 }
44}
45
46pub struct RemoteConnectionModal {
47 pub prompt: Entity<RemoteConnectionPrompt>,
48 paths: Vec<PathBuf>,
49 finished: bool,
50}
51
52impl RemoteConnectionPrompt {
53 pub fn new(
54 connection_string: String,
55 nickname: Option<String>,
56 is_wsl: bool,
57 is_devcontainer: bool,
58 window: &mut Window,
59 cx: &mut Context<Self>,
60 ) -> Self {
61 let editor_factory = ERASED_EDITOR_FACTORY
62 .get()
63 .expect("ErasedEditorFactory to be initialized");
64 let editor = (editor_factory)(window, cx);
65
66 Self {
67 connection_string: connection_string.into(),
68 nickname: nickname.map(|nickname| nickname.into()),
69 is_wsl,
70 is_devcontainer,
71 editor,
72 status_message: None,
73 cancellation: None,
74 prompt: None,
75 is_password_prompt: false,
76 is_masked: true,
77 }
78 }
79
80 pub fn set_cancellation_tx(&mut self, tx: oneshot::Sender<()>) {
81 self.cancellation = Some(tx);
82 }
83
84 pub fn set_prompt(
85 &mut self,
86 prompt: String,
87 tx: oneshot::Sender<EncryptedPassword>,
88 window: &mut Window,
89 cx: &mut Context<Self>,
90 ) {
91 let is_yes_no = prompt.contains("yes/no");
92 self.is_password_prompt = !is_yes_no;
93 self.is_masked = !is_yes_no;
94 self.editor.set_masked(self.is_masked, window, cx);
95
96 let markdown = cx.new(|cx| Markdown::new_text(prompt.into(), cx));
97 self.prompt = Some((markdown, tx));
98 self.status_message.take();
99 window.focus(&self.editor.focus_handle(cx), cx);
100 cx.notify();
101 }
102
103 pub fn set_status(&mut self, status: Option<String>, cx: &mut Context<Self>) {
104 self.status_message = status.map(|s| s.into());
105 cx.notify();
106 }
107
108 pub fn confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
109 if let Some((_, tx)) = self.prompt.take() {
110 self.status_message = Some("Connecting".into());
111
112 let pw = self.editor.text(cx);
113 if let Ok(secure) = EncryptedPassword::try_from(pw.as_ref()) {
114 tx.send(secure).ok();
115 }
116 self.editor.clear(window, cx);
117 }
118 }
119}
120
121impl Render for RemoteConnectionPrompt {
122 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
123 let theme = ThemeSettings::get_global(cx);
124
125 let mut text_style = window.text_style();
126 let refinement = TextStyleRefinement {
127 font_family: Some(theme.buffer_font.family.clone()),
128 font_features: Some(FontFeatures::disable_ligatures()),
129 font_size: Some(theme.buffer_font_size(cx).into()),
130 color: Some(cx.theme().colors().editor_foreground),
131 background_color: Some(gpui::transparent_black()),
132 ..Default::default()
133 };
134
135 text_style.refine(&refinement);
136 let markdown_style = MarkdownStyle {
137 base_text_style: text_style,
138 selection_background_color: cx.theme().colors().element_selection_background,
139 ..Default::default()
140 };
141
142 let is_password_prompt = self.is_password_prompt;
143 let is_masked = self.is_masked;
144 let (masked_password_icon, masked_password_tooltip) = if is_masked {
145 (IconName::Eye, "Toggle to Unmask Password")
146 } else {
147 (IconName::EyeOff, "Toggle to Mask Password")
148 };
149
150 v_flex()
151 .key_context("PasswordPrompt")
152 .p_2()
153 .size_full()
154 .when_some(self.prompt.as_ref(), |this, prompt| {
155 this.child(
156 v_flex()
157 .text_sm()
158 .size_full()
159 .overflow_hidden()
160 .child(
161 h_flex()
162 .w_full()
163 .justify_between()
164 .child(MarkdownElement::new(prompt.0.clone(), markdown_style))
165 .when(is_password_prompt, |this| {
166 this.child(
167 IconButton::new("toggle_mask", masked_password_icon)
168 .icon_size(IconSize::Small)
169 .tooltip(Tooltip::text(masked_password_tooltip))
170 .on_click(cx.listener(|this, _, window, cx| {
171 this.is_masked = !this.is_masked;
172 this.editor.set_masked(this.is_masked, window, cx);
173 window.focus(&this.editor.focus_handle(cx), cx);
174 cx.notify();
175 })),
176 )
177 }),
178 )
179 .child(div().flex_1().child(self.editor.render(window, cx))),
180 )
181 .when(window.capslock().on, |this| {
182 this.child(
183 h_flex()
184 .py_0p5()
185 .min_w_0()
186 .w_full()
187 .gap_1()
188 .child(
189 Icon::new(IconName::Warning)
190 .size(IconSize::Small)
191 .color(Color::Muted),
192 )
193 .child(
194 Label::new("Caps lock is on.")
195 .size(LabelSize::Small)
196 .color(Color::Muted),
197 ),
198 )
199 })
200 })
201 .when_some(self.status_message.clone(), |this, status_message| {
202 this.child(
203 h_flex()
204 .min_w_0()
205 .w_full()
206 .mt_1()
207 .gap_1()
208 .child(
209 Icon::new(IconName::LoadCircle)
210 .size(IconSize::Small)
211 .color(Color::Muted)
212 .with_rotate_animation(2),
213 )
214 .child(
215 Label::new(format!("{}…", status_message))
216 .size(LabelSize::Small)
217 .color(Color::Muted)
218 .truncate()
219 .flex_1(),
220 ),
221 )
222 })
223 }
224}
225
226impl RemoteConnectionModal {
227 pub fn new(
228 connection_options: &RemoteConnectionOptions,
229 paths: Vec<PathBuf>,
230 window: &mut Window,
231 cx: &mut Context<Self>,
232 ) -> Self {
233 let (connection_string, nickname, is_wsl, is_devcontainer) = match connection_options {
234 RemoteConnectionOptions::Ssh(options) => (
235 options.connection_string(),
236 options.nickname.clone(),
237 false,
238 false,
239 ),
240 RemoteConnectionOptions::Wsl(options) => {
241 (options.distro_name.clone(), None, true, false)
242 }
243 RemoteConnectionOptions::Docker(options) => (options.name.clone(), None, false, true),
244 #[cfg(any(test, feature = "test-support"))]
245 RemoteConnectionOptions::Mock(options) => {
246 (format!("mock-{}", options.id), None, false, false)
247 }
248 };
249 Self {
250 prompt: cx.new(|cx| {
251 RemoteConnectionPrompt::new(
252 connection_string,
253 nickname,
254 is_wsl,
255 is_devcontainer,
256 window,
257 cx,
258 )
259 }),
260 finished: false,
261 paths,
262 }
263 }
264
265 fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
266 self.prompt
267 .update(cx, |prompt, cx| prompt.confirm(window, cx))
268 }
269
270 pub fn finished(&mut self, cx: &mut Context<Self>) {
271 self.finished = true;
272 cx.emit(DismissEvent);
273 }
274
275 fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
276 if let Some(tx) = self
277 .prompt
278 .update(cx, |prompt, _cx| prompt.cancellation.take())
279 {
280 log::debug!("cancelling remote connection");
281 tx.send(()).ok();
282 }
283 self.finished(cx);
284 }
285}
286
287pub struct SshConnectionHeader {
288 pub connection_string: SharedString,
289 pub paths: Vec<PathBuf>,
290 pub nickname: Option<SharedString>,
291 pub is_wsl: bool,
292 pub is_devcontainer: bool,
293}
294
295impl RenderOnce for SshConnectionHeader {
296 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
297 let theme = cx.theme();
298
299 let mut header_color = theme.colors().text;
300 header_color.fade_out(0.96);
301
302 let (main_label, meta_label) = if let Some(nickname) = self.nickname {
303 (nickname, Some(format!("({})", self.connection_string)))
304 } else {
305 (self.connection_string, None)
306 };
307
308 let icon = if self.is_wsl {
309 IconName::Linux
310 } else if self.is_devcontainer {
311 IconName::Box
312 } else {
313 IconName::Server
314 };
315
316 h_flex()
317 .px(DynamicSpacing::Base12.rems(cx))
318 .pt(DynamicSpacing::Base08.rems(cx))
319 .pb(DynamicSpacing::Base04.rems(cx))
320 .rounded_t_sm()
321 .w_full()
322 .gap_1p5()
323 .child(Icon::new(icon).size(IconSize::Small))
324 .child(
325 h_flex()
326 .gap_1()
327 .overflow_x_hidden()
328 .child(
329 div()
330 .max_w_96()
331 .overflow_x_hidden()
332 .text_ellipsis()
333 .child(Headline::new(main_label).size(HeadlineSize::XSmall)),
334 )
335 .children(
336 meta_label.map(|label| {
337 Label::new(label).color(Color::Muted).size(LabelSize::Small)
338 }),
339 )
340 .child(div().overflow_x_hidden().text_ellipsis().children(
341 self.paths.into_iter().map(|path| {
342 Label::new(path.to_string_lossy().into_owned())
343 .size(LabelSize::Small)
344 .color(Color::Muted)
345 }),
346 )),
347 )
348 }
349}
350
351impl Render for RemoteConnectionModal {
352 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
353 let nickname = self.prompt.read(cx).nickname.clone();
354 let connection_string = self.prompt.read(cx).connection_string.clone();
355 let is_wsl = self.prompt.read(cx).is_wsl;
356 let is_devcontainer = self.prompt.read(cx).is_devcontainer;
357
358 let theme = cx.theme().clone();
359 let body_color = theme.colors().editor_background;
360
361 v_flex()
362 .elevation_3(cx)
363 .w(rems(34.))
364 .border_1()
365 .border_color(theme.colors().border)
366 .key_context("SshConnectionModal")
367 .track_focus(&self.focus_handle(cx))
368 .on_action(cx.listener(Self::dismiss))
369 .on_action(cx.listener(Self::confirm))
370 .child(
371 SshConnectionHeader {
372 paths: self.paths.clone(),
373 connection_string,
374 nickname,
375 is_wsl,
376 is_devcontainer,
377 }
378 .render(window, cx),
379 )
380 .child(
381 div()
382 .w_full()
383 .bg(body_color)
384 .border_y_1()
385 .border_color(theme.colors().border_variant)
386 .child(self.prompt.clone()),
387 )
388 .child(
389 div().w_full().py_1().child(
390 ListItem::new("li-devcontainer-go-back")
391 .inset(true)
392 .spacing(ui::ListItemSpacing::Sparse)
393 .start_slot(Icon::new(IconName::Close).color(Color::Muted))
394 .child(Label::new("Cancel"))
395 .end_slot(
396 KeyBinding::for_action_in(&menu::Cancel, &self.focus_handle(cx), cx)
397 .size(rems_from_px(12.)),
398 )
399 .on_click(cx.listener(|this, _, window, cx| {
400 this.dismiss(&menu::Cancel, window, cx);
401 })),
402 ),
403 )
404 }
405}
406
407impl Focusable for RemoteConnectionModal {
408 fn focus_handle(&self, cx: &gpui::App) -> gpui::FocusHandle {
409 self.prompt.read(cx).editor.focus_handle(cx)
410 }
411}
412
413impl EventEmitter<DismissEvent> for RemoteConnectionModal {}
414
415impl ModalView for RemoteConnectionModal {
416 fn on_before_dismiss(
417 &mut self,
418 _window: &mut Window,
419 _: &mut Context<Self>,
420 ) -> DismissDecision {
421 DismissDecision::Dismiss(self.finished)
422 }
423
424 fn fade_out_background(&self) -> bool {
425 true
426 }
427}
428
429#[derive(Clone)]
430pub struct RemoteClientDelegate {
431 window: AnyWindowHandle,
432 ui: WeakEntity<RemoteConnectionPrompt>,
433 known_password: Option<EncryptedPassword>,
434}
435
436impl RemoteClientDelegate {
437 pub fn new(
438 window: AnyWindowHandle,
439 ui: WeakEntity<RemoteConnectionPrompt>,
440 known_password: Option<EncryptedPassword>,
441 ) -> Self {
442 Self {
443 window,
444 ui,
445 known_password,
446 }
447 }
448}
449
450impl remote::RemoteClientDelegate for RemoteClientDelegate {
451 fn ask_password(
452 &self,
453 prompt: String,
454 tx: oneshot::Sender<EncryptedPassword>,
455 cx: &mut AsyncApp,
456 ) {
457 let mut known_password = self.known_password.clone();
458 if let Some(password) = known_password.take() {
459 tx.send(password).ok();
460 } else {
461 self.window
462 .update(cx, |_, window, cx| {
463 self.ui.update(cx, |modal, cx| {
464 modal.set_prompt(prompt, tx, window, cx);
465 })
466 })
467 .ok();
468 }
469 }
470
471 fn set_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
472 self.update_status(status, cx)
473 }
474
475 fn download_server_binary_locally(
476 &self,
477 platform: RemotePlatform,
478 release_channel: ReleaseChannel,
479 version: Option<Version>,
480 cx: &mut AsyncApp,
481 ) -> Task<anyhow::Result<PathBuf>> {
482 let this = self.clone();
483 cx.spawn(async move |cx| {
484 AutoUpdater::download_remote_server_release(
485 release_channel,
486 version.clone(),
487 platform.os.as_str(),
488 platform.arch.as_str(),
489 move |status, cx| this.set_status(Some(status), cx),
490 cx,
491 )
492 .await
493 .with_context(|| {
494 format!(
495 "Downloading remote server binary (version: {}, os: {}, arch: {})",
496 version
497 .as_ref()
498 .map(|v| format!("{}", v))
499 .unwrap_or("unknown".to_string()),
500 platform.os,
501 platform.arch,
502 )
503 })
504 })
505 }
506
507 fn get_download_url(
508 &self,
509 platform: RemotePlatform,
510 release_channel: ReleaseChannel,
511 version: Option<Version>,
512 cx: &mut AsyncApp,
513 ) -> Task<Result<Option<String>>> {
514 cx.spawn(async move |cx| {
515 AutoUpdater::get_remote_server_release_url(
516 release_channel,
517 version,
518 platform.os.as_str(),
519 platform.arch.as_str(),
520 cx,
521 )
522 .await
523 })
524 }
525}
526
527impl RemoteClientDelegate {
528 fn update_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
529 cx.update(|cx| {
530 self.ui
531 .update(cx, |modal, cx| {
532 modal.set_status(status.map(|s| s.to_string()), cx);
533 })
534 .ok()
535 });
536 }
537}
538
539/// Shows a [`RemoteConnectionModal`] on the given workspace and establishes
540/// a remote connection. This is a convenience wrapper around
541/// [`RemoteConnectionModal`] and [`connect`] suitable for use as the
542/// `connect_remote` callback in [`MultiWorkspace::find_or_create_workspace`].
543///
544/// When the global connection pool already has a live connection for the
545/// given options, the modal is skipped entirely and the connection is
546/// reused silently.
547pub fn connect_with_modal(
548 workspace: &Entity<Workspace>,
549 connection_options: RemoteConnectionOptions,
550 window: &mut Window,
551 cx: &mut App,
552) -> Task<Result<Option<Entity<RemoteClient>>>> {
553 if remote::has_active_connection(&connection_options, cx) {
554 return connect_reusing_pool(connection_options, cx);
555 }
556
557 workspace.update(cx, |workspace, cx| {
558 workspace.toggle_modal(window, cx, |window, cx| {
559 RemoteConnectionModal::new(&connection_options, Vec::new(), window, cx)
560 });
561 let Some(modal) = workspace.active_modal::<RemoteConnectionModal>(cx) else {
562 return Task::ready(Err(anyhow::anyhow!(
563 "Failed to open remote connection dialog"
564 )));
565 };
566 let prompt = modal.read(cx).prompt.clone();
567 connect(
568 ConnectionIdentifier::setup(),
569 connection_options,
570 prompt,
571 window,
572 cx,
573 )
574 })
575}
576
577/// Creates a [`RemoteClient`] by reusing an existing connection from the
578/// global pool. No interactive UI is shown. This should only be called
579/// when [`remote::has_active_connection`] returns `true`.
580fn connect_reusing_pool(
581 connection_options: RemoteConnectionOptions,
582 cx: &mut App,
583) -> Task<Result<Option<Entity<RemoteClient>>>> {
584 let delegate: Arc<dyn remote::RemoteClientDelegate> = Arc::new(BackgroundRemoteClientDelegate);
585
586 cx.spawn(async move |cx| {
587 let connection = remote::connect(connection_options, delegate.clone(), cx).await?;
588
589 let (_cancel_guard, cancel_rx) = oneshot::channel::<()>();
590 cx.update(|cx| {
591 RemoteClient::new(
592 ConnectionIdentifier::setup(),
593 connection,
594 cancel_rx,
595 delegate,
596 cx,
597 )
598 })
599 .await
600 })
601}
602
603/// Delegate for remote connections that reuse an existing pooled
604/// connection. Password prompts are not expected (the SSH transport
605/// is already established), but server binary downloads are supported
606/// via [`AutoUpdater`].
607struct BackgroundRemoteClientDelegate;
608
609impl remote::RemoteClientDelegate for BackgroundRemoteClientDelegate {
610 fn ask_password(
611 &self,
612 prompt: String,
613 _tx: oneshot::Sender<EncryptedPassword>,
614 _cx: &mut AsyncApp,
615 ) {
616 log::warn!(
617 "Pooled remote connection unexpectedly requires a password \
618 (prompt: {prompt})"
619 );
620 }
621
622 fn set_status(&self, _status: Option<&str>, _cx: &mut AsyncApp) {}
623
624 fn download_server_binary_locally(
625 &self,
626 platform: RemotePlatform,
627 release_channel: ReleaseChannel,
628 version: Option<Version>,
629 cx: &mut AsyncApp,
630 ) -> Task<anyhow::Result<PathBuf>> {
631 cx.spawn(async move |cx| {
632 AutoUpdater::download_remote_server_release(
633 release_channel,
634 version.clone(),
635 platform.os.as_str(),
636 platform.arch.as_str(),
637 |_status, _cx| {},
638 cx,
639 )
640 .await
641 .with_context(|| {
642 format!(
643 "Downloading remote server binary (version: {}, os: {}, arch: {})",
644 version
645 .as_ref()
646 .map(|v| format!("{v}"))
647 .unwrap_or("unknown".to_string()),
648 platform.os,
649 platform.arch,
650 )
651 })
652 })
653 }
654
655 fn get_download_url(
656 &self,
657 platform: RemotePlatform,
658 release_channel: ReleaseChannel,
659 version: Option<Version>,
660 cx: &mut AsyncApp,
661 ) -> Task<Result<Option<String>>> {
662 cx.spawn(async move |cx| {
663 AutoUpdater::get_remote_server_release_url(
664 release_channel,
665 version,
666 platform.os.as_str(),
667 platform.arch.as_str(),
668 cx,
669 )
670 .await
671 })
672 }
673}
674
675pub fn connect(
676 unique_identifier: ConnectionIdentifier,
677 connection_options: RemoteConnectionOptions,
678 ui: Entity<RemoteConnectionPrompt>,
679 window: &mut Window,
680 cx: &mut App,
681) -> Task<Result<Option<Entity<RemoteClient>>>> {
682 let window = window.window_handle();
683 let known_password = match &connection_options {
684 RemoteConnectionOptions::Ssh(ssh_connection_options) => ssh_connection_options
685 .password
686 .as_deref()
687 .and_then(|pw| pw.try_into().ok()),
688 _ => None,
689 };
690 let (tx, mut rx) = oneshot::channel();
691 ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx));
692
693 let delegate = Arc::new(RemoteClientDelegate {
694 window,
695 ui: ui.downgrade(),
696 known_password,
697 });
698
699 cx.spawn(async move |cx| {
700 let connection = remote::connect(connection_options, delegate.clone(), cx);
701 let connection = select! {
702 _ = rx => return Ok(None),
703 result = connection.fuse() => result,
704 }?;
705
706 cx.update(|cx| remote::RemoteClient::new(unique_identifier, connection, rx, delegate, cx))
707 .await
708 })
709}
710
711use anyhow::Context as _;