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