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