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