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::ThemeSettings;
17use ui::{
18 ActiveTheme, Color, CommonAnimationExt, Context, InteractiveElement, IntoElement, KeyBinding,
19 LabelCommon, ListItem, Styled, Window, prelude::*,
20};
21use ui_input::{ERASED_EDITOR_FACTORY, ErasedEditor};
22use workspace::{DismissDecision, ModalView};
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}
34
35impl Drop for RemoteConnectionPrompt {
36 fn drop(&mut self) {
37 if let Some(cancel) = self.cancellation.take() {
38 log::debug!("cancelling remote connection");
39 cancel.send(()).ok();
40 }
41 }
42}
43
44pub struct RemoteConnectionModal {
45 pub prompt: Entity<RemoteConnectionPrompt>,
46 paths: Vec<PathBuf>,
47 finished: bool,
48}
49
50impl RemoteConnectionPrompt {
51 pub fn new(
52 connection_string: String,
53 nickname: Option<String>,
54 is_wsl: bool,
55 is_devcontainer: bool,
56 window: &mut Window,
57 cx: &mut Context<Self>,
58 ) -> Self {
59 let editor_factory = ERASED_EDITOR_FACTORY
60 .get()
61 .expect("ErasedEditorFactory to be initialized");
62 let editor = (editor_factory)(window, cx);
63
64 Self {
65 connection_string: connection_string.into(),
66 nickname: nickname.map(|nickname| nickname.into()),
67 is_wsl,
68 is_devcontainer,
69 editor,
70 status_message: None,
71 cancellation: None,
72 prompt: None,
73 }
74 }
75
76 pub fn set_cancellation_tx(&mut self, tx: oneshot::Sender<()>) {
77 self.cancellation = Some(tx);
78 }
79
80 pub fn set_prompt(
81 &mut self,
82 prompt: String,
83 tx: oneshot::Sender<EncryptedPassword>,
84 window: &mut Window,
85 cx: &mut Context<Self>,
86 ) {
87 let is_yes_no = prompt.contains("yes/no");
88 self.editor.set_masked(!is_yes_no, window, cx);
89
90 let markdown = cx.new(|cx| Markdown::new_text(prompt.into(), cx));
91 self.prompt = Some((markdown, tx));
92 self.status_message.take();
93 window.focus(&self.editor.focus_handle(cx), cx);
94 cx.notify();
95 }
96
97 pub fn set_status(&mut self, status: Option<String>, cx: &mut Context<Self>) {
98 self.status_message = status.map(|s| s.into());
99 cx.notify();
100 }
101
102 pub fn confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
103 if let Some((_, tx)) = self.prompt.take() {
104 self.status_message = Some("Connecting".into());
105
106 let pw = self.editor.text(cx);
107 if let Ok(secure) = EncryptedPassword::try_from(pw.as_ref()) {
108 tx.send(secure).ok();
109 }
110 self.editor.clear(window, cx);
111 }
112 }
113}
114
115impl Render for RemoteConnectionPrompt {
116 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
117 let theme = ThemeSettings::get_global(cx);
118
119 let mut text_style = window.text_style();
120 let refinement = TextStyleRefinement {
121 font_family: Some(theme.buffer_font.family.clone()),
122 font_features: Some(FontFeatures::disable_ligatures()),
123 font_size: Some(theme.buffer_font_size(cx).into()),
124 color: Some(cx.theme().colors().editor_foreground),
125 background_color: Some(gpui::transparent_black()),
126 ..Default::default()
127 };
128
129 text_style.refine(&refinement);
130 let markdown_style = MarkdownStyle {
131 base_text_style: text_style,
132 selection_background_color: cx.theme().colors().element_selection_background,
133 ..Default::default()
134 };
135
136 v_flex()
137 .key_context("PasswordPrompt")
138 .p_2()
139 .size_full()
140 .text_buffer(cx)
141 .when_some(self.status_message.clone(), |el, status_message| {
142 el.child(
143 h_flex()
144 .gap_2()
145 .child(
146 Icon::new(IconName::ArrowCircle)
147 .color(Color::Muted)
148 .with_rotate_animation(2),
149 )
150 .child(
151 div()
152 .text_ellipsis()
153 .overflow_x_hidden()
154 .child(format!("{}…", status_message)),
155 ),
156 )
157 })
158 .when_some(self.prompt.as_ref(), |el, prompt| {
159 el.child(
160 div()
161 .size_full()
162 .overflow_hidden()
163 .child(MarkdownElement::new(prompt.0.clone(), markdown_style))
164 .child(self.editor.render(window, cx)),
165 )
166 .when(window.capslock().on, |el| {
167 el.child(Label::new("⚠️ ⇪ is on"))
168 })
169 })
170 }
171}
172
173impl RemoteConnectionModal {
174 pub fn new(
175 connection_options: &RemoteConnectionOptions,
176 paths: Vec<PathBuf>,
177 window: &mut Window,
178 cx: &mut Context<Self>,
179 ) -> Self {
180 let (connection_string, nickname, is_wsl, is_devcontainer) = match connection_options {
181 RemoteConnectionOptions::Ssh(options) => (
182 options.connection_string(),
183 options.nickname.clone(),
184 false,
185 false,
186 ),
187 RemoteConnectionOptions::Wsl(options) => {
188 (options.distro_name.clone(), None, true, false)
189 }
190 RemoteConnectionOptions::Docker(options) => (options.name.clone(), None, false, true),
191 #[cfg(any(test, feature = "test-support"))]
192 RemoteConnectionOptions::Mock(options) => {
193 (format!("mock-{}", options.id), None, false, false)
194 }
195 };
196 Self {
197 prompt: cx.new(|cx| {
198 RemoteConnectionPrompt::new(
199 connection_string,
200 nickname,
201 is_wsl,
202 is_devcontainer,
203 window,
204 cx,
205 )
206 }),
207 finished: false,
208 paths,
209 }
210 }
211
212 fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
213 self.prompt
214 .update(cx, |prompt, cx| prompt.confirm(window, cx))
215 }
216
217 pub fn finished(&mut self, cx: &mut Context<Self>) {
218 self.finished = true;
219 cx.emit(DismissEvent);
220 }
221
222 fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
223 if let Some(tx) = self
224 .prompt
225 .update(cx, |prompt, _cx| prompt.cancellation.take())
226 {
227 log::debug!("cancelling remote connection");
228 tx.send(()).ok();
229 }
230 self.finished(cx);
231 }
232}
233
234pub struct SshConnectionHeader {
235 pub connection_string: SharedString,
236 pub paths: Vec<PathBuf>,
237 pub nickname: Option<SharedString>,
238 pub is_wsl: bool,
239 pub is_devcontainer: bool,
240}
241
242impl RenderOnce for SshConnectionHeader {
243 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
244 let theme = cx.theme();
245
246 let mut header_color = theme.colors().text;
247 header_color.fade_out(0.96);
248
249 let (main_label, meta_label) = if let Some(nickname) = self.nickname {
250 (nickname, Some(format!("({})", self.connection_string)))
251 } else {
252 (self.connection_string, None)
253 };
254
255 let icon = if self.is_wsl {
256 IconName::Linux
257 } else if self.is_devcontainer {
258 IconName::Box
259 } else {
260 IconName::Server
261 };
262
263 h_flex()
264 .px(DynamicSpacing::Base12.rems(cx))
265 .pt(DynamicSpacing::Base08.rems(cx))
266 .pb(DynamicSpacing::Base04.rems(cx))
267 .rounded_t_sm()
268 .w_full()
269 .gap_1p5()
270 .child(Icon::new(icon).size(IconSize::Small))
271 .child(
272 h_flex()
273 .gap_1()
274 .overflow_x_hidden()
275 .child(
276 div()
277 .max_w_96()
278 .overflow_x_hidden()
279 .text_ellipsis()
280 .child(Headline::new(main_label).size(HeadlineSize::XSmall)),
281 )
282 .children(
283 meta_label.map(|label| {
284 Label::new(label).color(Color::Muted).size(LabelSize::Small)
285 }),
286 )
287 .child(div().overflow_x_hidden().text_ellipsis().children(
288 self.paths.into_iter().map(|path| {
289 Label::new(path.to_string_lossy().into_owned())
290 .size(LabelSize::Small)
291 .color(Color::Muted)
292 }),
293 )),
294 )
295 }
296}
297
298impl Render for RemoteConnectionModal {
299 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
300 let nickname = self.prompt.read(cx).nickname.clone();
301 let connection_string = self.prompt.read(cx).connection_string.clone();
302 let is_wsl = self.prompt.read(cx).is_wsl;
303 let is_devcontainer = self.prompt.read(cx).is_devcontainer;
304
305 let theme = cx.theme().clone();
306 let body_color = theme.colors().editor_background;
307
308 v_flex()
309 .elevation_3(cx)
310 .w(rems(34.))
311 .border_1()
312 .border_color(theme.colors().border)
313 .key_context("SshConnectionModal")
314 .track_focus(&self.focus_handle(cx))
315 .on_action(cx.listener(Self::dismiss))
316 .on_action(cx.listener(Self::confirm))
317 .child(
318 SshConnectionHeader {
319 paths: self.paths.clone(),
320 connection_string,
321 nickname,
322 is_wsl,
323 is_devcontainer,
324 }
325 .render(window, cx),
326 )
327 .child(
328 div()
329 .w_full()
330 .bg(body_color)
331 .border_y_1()
332 .border_color(theme.colors().border_variant)
333 .child(self.prompt.clone()),
334 )
335 .child(
336 div().w_full().py_1().child(
337 ListItem::new("li-devcontainer-go-back")
338 .inset(true)
339 .spacing(ui::ListItemSpacing::Sparse)
340 .start_slot(Icon::new(IconName::Close).color(Color::Muted))
341 .child(Label::new("Cancel"))
342 .end_slot(
343 KeyBinding::for_action_in(&menu::Cancel, &self.focus_handle(cx), cx)
344 .size(rems_from_px(12.)),
345 )
346 .on_click(cx.listener(|this, _, window, cx| {
347 this.dismiss(&menu::Cancel, window, cx);
348 })),
349 ),
350 )
351 }
352}
353
354impl Focusable for RemoteConnectionModal {
355 fn focus_handle(&self, cx: &gpui::App) -> gpui::FocusHandle {
356 self.prompt.read(cx).editor.focus_handle(cx)
357 }
358}
359
360impl EventEmitter<DismissEvent> for RemoteConnectionModal {}
361
362impl ModalView for RemoteConnectionModal {
363 fn on_before_dismiss(
364 &mut self,
365 _window: &mut Window,
366 _: &mut Context<Self>,
367 ) -> DismissDecision {
368 DismissDecision::Dismiss(self.finished)
369 }
370
371 fn fade_out_background(&self) -> bool {
372 true
373 }
374}
375
376#[derive(Clone)]
377pub struct RemoteClientDelegate {
378 window: AnyWindowHandle,
379 ui: WeakEntity<RemoteConnectionPrompt>,
380 known_password: Option<EncryptedPassword>,
381}
382
383impl RemoteClientDelegate {
384 pub fn new(
385 window: AnyWindowHandle,
386 ui: WeakEntity<RemoteConnectionPrompt>,
387 known_password: Option<EncryptedPassword>,
388 ) -> Self {
389 Self {
390 window,
391 ui,
392 known_password,
393 }
394 }
395}
396
397impl remote::RemoteClientDelegate for RemoteClientDelegate {
398 fn ask_password(
399 &self,
400 prompt: String,
401 tx: oneshot::Sender<EncryptedPassword>,
402 cx: &mut AsyncApp,
403 ) {
404 let mut known_password = self.known_password.clone();
405 if let Some(password) = known_password.take() {
406 tx.send(password).ok();
407 } else {
408 self.window
409 .update(cx, |_, window, cx| {
410 self.ui.update(cx, |modal, cx| {
411 modal.set_prompt(prompt, tx, window, cx);
412 })
413 })
414 .ok();
415 }
416 }
417
418 fn set_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
419 self.update_status(status, cx)
420 }
421
422 fn download_server_binary_locally(
423 &self,
424 platform: RemotePlatform,
425 release_channel: ReleaseChannel,
426 version: Option<Version>,
427 cx: &mut AsyncApp,
428 ) -> Task<anyhow::Result<PathBuf>> {
429 let this = self.clone();
430 cx.spawn(async move |cx| {
431 AutoUpdater::download_remote_server_release(
432 release_channel,
433 version.clone(),
434 platform.os.as_str(),
435 platform.arch.as_str(),
436 move |status, cx| this.set_status(Some(status), cx),
437 cx,
438 )
439 .await
440 .with_context(|| {
441 format!(
442 "Downloading remote server binary (version: {}, os: {}, arch: {})",
443 version
444 .as_ref()
445 .map(|v| format!("{}", v))
446 .unwrap_or("unknown".to_string()),
447 platform.os,
448 platform.arch,
449 )
450 })
451 })
452 }
453
454 fn get_download_url(
455 &self,
456 platform: RemotePlatform,
457 release_channel: ReleaseChannel,
458 version: Option<Version>,
459 cx: &mut AsyncApp,
460 ) -> Task<Result<Option<String>>> {
461 cx.spawn(async move |cx| {
462 AutoUpdater::get_remote_server_release_url(
463 release_channel,
464 version,
465 platform.os.as_str(),
466 platform.arch.as_str(),
467 cx,
468 )
469 .await
470 })
471 }
472}
473
474impl RemoteClientDelegate {
475 fn update_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
476 cx.update(|cx| {
477 self.ui
478 .update(cx, |modal, cx| {
479 modal.set_status(status.map(|s| s.to_string()), cx);
480 })
481 .ok()
482 });
483 }
484}
485
486pub fn connect(
487 unique_identifier: ConnectionIdentifier,
488 connection_options: RemoteConnectionOptions,
489 ui: Entity<RemoteConnectionPrompt>,
490 window: &mut Window,
491 cx: &mut App,
492) -> Task<Result<Option<Entity<RemoteClient>>>> {
493 let window = window.window_handle();
494 let known_password = match &connection_options {
495 RemoteConnectionOptions::Ssh(ssh_connection_options) => ssh_connection_options
496 .password
497 .as_deref()
498 .and_then(|pw| pw.try_into().ok()),
499 _ => None,
500 };
501 let (tx, mut rx) = oneshot::channel();
502 ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx));
503
504 let delegate = Arc::new(RemoteClientDelegate {
505 window,
506 ui: ui.downgrade(),
507 known_password,
508 });
509
510 cx.spawn(async move |cx| {
511 let connection = remote::connect(connection_options, delegate.clone(), cx);
512 let connection = select! {
513 _ = rx => return Ok(None),
514 result = connection.fuse() => result,
515 }?;
516
517 cx.update(|cx| remote::RemoteClient::new(unique_identifier, connection, rx, delegate, cx))
518 .await
519 })
520}
521
522use anyhow::Context as _;