1use std::collections::BTreeSet;
2use std::{path::PathBuf, sync::Arc, time::Duration};
3
4use anyhow::{Result, anyhow};
5use auto_update::AutoUpdater;
6use editor::Editor;
7use extension_host::ExtensionStore;
8use futures::channel::oneshot;
9use gpui::{
10 Animation, AnimationExt, AnyWindowHandle, App, AsyncApp, DismissEvent, Entity, EventEmitter,
11 Focusable, FontFeatures, ParentElement as _, PromptLevel, Render, SemanticVersion,
12 SharedString, Task, TextStyleRefinement, Transformation, WeakEntity, percentage,
13};
14
15use language::CursorShape;
16use markdown::{Markdown, MarkdownElement, MarkdownStyle};
17use release_channel::ReleaseChannel;
18use remote::ssh_session::{ConnectionIdentifier, SshPortForwardOption};
19use remote::{SshConnectionOptions, SshPlatform, SshRemoteClient};
20use schemars::JsonSchema;
21use serde::{Deserialize, Serialize};
22use settings::{Settings, SettingsSources};
23use theme::ThemeSettings;
24use ui::{
25 ActiveTheme, Color, Context, Icon, IconName, IconSize, InteractiveElement, IntoElement, Label,
26 LabelCommon, Styled, Window, prelude::*,
27};
28use workspace::{AppState, ModalView, Workspace};
29
30#[derive(Deserialize)]
31pub struct SshSettings {
32 pub ssh_connections: Option<Vec<SshConnection>>,
33}
34
35impl SshSettings {
36 pub fn ssh_connections(&self) -> impl Iterator<Item = SshConnection> + use<> {
37 self.ssh_connections.clone().into_iter().flatten()
38 }
39
40 pub fn connection_options_for(
41 &self,
42 host: String,
43 port: Option<u16>,
44 username: Option<String>,
45 ) -> SshConnectionOptions {
46 for conn in self.ssh_connections() {
47 if conn.host == host && conn.username == username && conn.port == port {
48 return SshConnectionOptions {
49 nickname: conn.nickname,
50 upload_binary_over_ssh: conn.upload_binary_over_ssh.unwrap_or_default(),
51 args: Some(conn.args),
52 host,
53 port,
54 username,
55 port_forwards: conn.port_forwards,
56 password: None,
57 };
58 }
59 }
60 SshConnectionOptions {
61 host,
62 port,
63 username,
64 ..Default::default()
65 }
66 }
67}
68
69#[derive(Clone, Default, Serialize, Deserialize, PartialEq, JsonSchema)]
70pub struct SshConnection {
71 pub host: SharedString,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 pub username: Option<String>,
74 #[serde(skip_serializing_if = "Option::is_none")]
75 pub port: Option<u16>,
76 #[serde(skip_serializing_if = "Vec::is_empty")]
77 #[serde(default)]
78 pub args: Vec<String>,
79 #[serde(default)]
80 pub projects: BTreeSet<SshProject>,
81 /// Name to use for this server in UI.
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub nickname: Option<String>,
84 // By default Zed will download the binary to the host directly.
85 // If this is set to true, Zed will download the binary to your local machine,
86 // and then upload it over the SSH connection. Useful if your SSH server has
87 // limited outbound internet access.
88 #[serde(skip_serializing_if = "Option::is_none")]
89 pub upload_binary_over_ssh: Option<bool>,
90
91 #[serde(skip_serializing_if = "Option::is_none")]
92 pub port_forwards: Option<Vec<SshPortForwardOption>>,
93}
94
95impl From<SshConnection> for SshConnectionOptions {
96 fn from(val: SshConnection) -> Self {
97 SshConnectionOptions {
98 host: val.host.into(),
99 username: val.username,
100 port: val.port,
101 password: None,
102 args: Some(val.args),
103 nickname: val.nickname,
104 upload_binary_over_ssh: val.upload_binary_over_ssh.unwrap_or_default(),
105 port_forwards: val.port_forwards,
106 }
107 }
108}
109
110#[derive(Clone, Default, Serialize, PartialEq, Eq, PartialOrd, Ord, Deserialize, JsonSchema)]
111pub struct SshProject {
112 pub paths: Vec<String>,
113}
114
115#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
116pub struct RemoteSettingsContent {
117 pub ssh_connections: Option<Vec<SshConnection>>,
118}
119
120impl Settings for SshSettings {
121 const KEY: Option<&'static str> = None;
122
123 type FileContent = RemoteSettingsContent;
124
125 fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> Result<Self> {
126 sources.json_merge()
127 }
128}
129
130pub struct SshPrompt {
131 connection_string: SharedString,
132 nickname: Option<SharedString>,
133 status_message: Option<SharedString>,
134 prompt: Option<(Entity<Markdown>, oneshot::Sender<String>)>,
135 cancellation: Option<oneshot::Sender<()>>,
136 editor: Entity<Editor>,
137}
138
139impl Drop for SshPrompt {
140 fn drop(&mut self) {
141 if let Some(cancel) = self.cancellation.take() {
142 cancel.send(()).ok();
143 }
144 }
145}
146
147pub struct SshConnectionModal {
148 pub(crate) prompt: Entity<SshPrompt>,
149 paths: Vec<PathBuf>,
150 finished: bool,
151}
152
153impl SshPrompt {
154 pub(crate) fn new(
155 connection_options: &SshConnectionOptions,
156 window: &mut Window,
157 cx: &mut Context<Self>,
158 ) -> Self {
159 let connection_string = connection_options.connection_string().into();
160 let nickname = connection_options.nickname.clone().map(|s| s.into());
161
162 Self {
163 connection_string,
164 nickname,
165 editor: cx.new(|cx| Editor::single_line(window, cx)),
166 status_message: None,
167 cancellation: None,
168 prompt: None,
169 }
170 }
171
172 pub fn set_cancellation_tx(&mut self, tx: oneshot::Sender<()>) {
173 self.cancellation = Some(tx);
174 }
175
176 pub fn set_prompt(
177 &mut self,
178 prompt: String,
179 tx: oneshot::Sender<String>,
180 window: &mut Window,
181 cx: &mut Context<Self>,
182 ) {
183 let theme = ThemeSettings::get_global(cx);
184
185 let refinement = TextStyleRefinement {
186 font_family: Some(theme.buffer_font.family.clone()),
187 font_features: Some(FontFeatures::disable_ligatures()),
188 font_size: Some(theme.buffer_font_size(cx).into()),
189 color: Some(cx.theme().colors().editor_foreground),
190 background_color: Some(gpui::transparent_black()),
191 ..Default::default()
192 };
193
194 self.editor.update(cx, |editor, cx| {
195 if prompt.contains("yes/no") {
196 editor.set_masked(false, cx);
197 } else {
198 editor.set_masked(true, cx);
199 }
200 editor.set_text_style_refinement(refinement);
201 editor.set_cursor_shape(CursorShape::Block, cx);
202 });
203
204 let markdown = cx.new(|cx| Markdown::new_text(prompt.into(), cx));
205 self.prompt = Some((markdown, tx));
206 self.status_message.take();
207 window.focus(&self.editor.focus_handle(cx));
208 cx.notify();
209 }
210
211 pub fn set_status(&mut self, status: Option<String>, cx: &mut Context<Self>) {
212 self.status_message = status.map(|s| s.into());
213 cx.notify();
214 }
215
216 pub fn confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
217 if let Some((_, tx)) = self.prompt.take() {
218 self.status_message = Some("Connecting".into());
219 self.editor.update(cx, |editor, cx| {
220 tx.send(editor.text(cx)).ok();
221 editor.clear(window, cx);
222 });
223 }
224 }
225}
226
227impl Render for SshPrompt {
228 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
229 let theme = ThemeSettings::get_global(cx);
230
231 let mut text_style = window.text_style();
232 let refinement = TextStyleRefinement {
233 font_family: Some(theme.buffer_font.family.clone()),
234 font_features: Some(FontFeatures::disable_ligatures()),
235 font_size: Some(theme.buffer_font_size(cx).into()),
236 color: Some(cx.theme().colors().editor_foreground),
237 background_color: Some(gpui::transparent_black()),
238 ..Default::default()
239 };
240
241 text_style.refine(&refinement);
242 let markdown_style = MarkdownStyle {
243 base_text_style: text_style,
244 selection_background_color: cx.theme().players().local().selection,
245 ..Default::default()
246 };
247
248 v_flex()
249 .key_context("PasswordPrompt")
250 .py_2()
251 .px_3()
252 .size_full()
253 .text_buffer(cx)
254 .when_some(self.status_message.clone(), |el, status_message| {
255 el.child(
256 h_flex()
257 .gap_1()
258 .child(
259 Icon::new(IconName::ArrowCircle)
260 .size(IconSize::Medium)
261 .with_animation(
262 "arrow-circle",
263 Animation::new(Duration::from_secs(2)).repeat(),
264 |icon, delta| {
265 icon.transform(Transformation::rotate(percentage(delta)))
266 },
267 ),
268 )
269 .child(
270 div()
271 .text_ellipsis()
272 .overflow_x_hidden()
273 .child(format!("{}…", status_message)),
274 ),
275 )
276 })
277 .when_some(self.prompt.as_ref(), |el, prompt| {
278 el.child(
279 div()
280 .size_full()
281 .overflow_hidden()
282 .child(MarkdownElement::new(prompt.0.clone(), markdown_style))
283 .child(self.editor.clone()),
284 )
285 })
286 }
287}
288
289impl SshConnectionModal {
290 pub(crate) fn new(
291 connection_options: &SshConnectionOptions,
292 paths: Vec<PathBuf>,
293 window: &mut Window,
294 cx: &mut Context<Self>,
295 ) -> Self {
296 Self {
297 prompt: cx.new(|cx| SshPrompt::new(connection_options, window, cx)),
298 finished: false,
299 paths,
300 }
301 }
302
303 fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
304 self.prompt
305 .update(cx, |prompt, cx| prompt.confirm(window, cx))
306 }
307
308 pub fn finished(&mut self, cx: &mut Context<Self>) {
309 self.finished = true;
310 cx.emit(DismissEvent);
311 }
312
313 fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
314 if let Some(tx) = self
315 .prompt
316 .update(cx, |prompt, _cx| prompt.cancellation.take())
317 {
318 tx.send(()).ok();
319 }
320 self.finished(cx);
321 }
322}
323
324pub(crate) struct SshConnectionHeader {
325 pub(crate) connection_string: SharedString,
326 pub(crate) paths: Vec<PathBuf>,
327 pub(crate) nickname: Option<SharedString>,
328}
329
330impl RenderOnce for SshConnectionHeader {
331 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
332 let theme = cx.theme();
333
334 let mut header_color = theme.colors().text;
335 header_color.fade_out(0.96);
336
337 let (main_label, meta_label) = if let Some(nickname) = self.nickname {
338 (nickname, Some(format!("({})", self.connection_string)))
339 } else {
340 (self.connection_string, None)
341 };
342
343 h_flex()
344 .px(DynamicSpacing::Base12.rems(cx))
345 .pt(DynamicSpacing::Base08.rems(cx))
346 .pb(DynamicSpacing::Base04.rems(cx))
347 .rounded_t_sm()
348 .w_full()
349 .gap_1p5()
350 .child(Icon::new(IconName::Server).size(IconSize::XSmall))
351 .child(
352 h_flex()
353 .gap_1()
354 .overflow_x_hidden()
355 .child(
356 div()
357 .max_w_96()
358 .overflow_x_hidden()
359 .text_ellipsis()
360 .child(Headline::new(main_label).size(HeadlineSize::XSmall)),
361 )
362 .children(
363 meta_label.map(|label| {
364 Label::new(label).color(Color::Muted).size(LabelSize::Small)
365 }),
366 )
367 .child(div().overflow_x_hidden().text_ellipsis().children(
368 self.paths.into_iter().map(|path| {
369 Label::new(path.to_string_lossy().to_string())
370 .size(LabelSize::Small)
371 .color(Color::Muted)
372 }),
373 )),
374 )
375 }
376}
377
378impl Render for SshConnectionModal {
379 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
380 let nickname = self.prompt.read(cx).nickname.clone();
381 let connection_string = self.prompt.read(cx).connection_string.clone();
382
383 let theme = cx.theme().clone();
384 let body_color = theme.colors().editor_background;
385
386 v_flex()
387 .elevation_3(cx)
388 .w(rems(34.))
389 .border_1()
390 .border_color(theme.colors().border)
391 .key_context("SshConnectionModal")
392 .track_focus(&self.focus_handle(cx))
393 .on_action(cx.listener(Self::dismiss))
394 .on_action(cx.listener(Self::confirm))
395 .child(
396 SshConnectionHeader {
397 paths: self.paths.clone(),
398 connection_string,
399 nickname,
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 SshConnectionModal {
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 SshConnectionModal {}
422
423impl ModalView for SshConnectionModal {
424 fn on_before_dismiss(
425 &mut self,
426 _window: &mut Window,
427 _: &mut Context<Self>,
428 ) -> workspace::DismissDecision {
429 return workspace::DismissDecision::Dismiss(self.finished);
430 }
431
432 fn fade_out_background(&self) -> bool {
433 true
434 }
435}
436
437#[derive(Clone)]
438pub struct SshClientDelegate {
439 window: AnyWindowHandle,
440 ui: WeakEntity<SshPrompt>,
441 known_password: Option<String>,
442}
443
444impl remote::SshClientDelegate for SshClientDelegate {
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: SshPlatform,
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 .map_err(|e| {
481 anyhow!(
482 "Failed to download 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 e
489 )
490 })?;
491 Ok(binary_path)
492 })
493 }
494
495 fn get_download_params(
496 &self,
497 platform: SshPlatform,
498 release_channel: ReleaseChannel,
499 version: Option<SemanticVersion>,
500 cx: &mut AsyncApp,
501 ) -> Task<Result<Option<(String, String)>>> {
502 cx.spawn(async move |cx| {
503 AutoUpdater::get_remote_server_release_url(
504 platform.os,
505 platform.arch,
506 release_channel,
507 version,
508 cx,
509 )
510 .await
511 })
512 }
513}
514
515impl SshClientDelegate {
516 fn update_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
517 self.window
518 .update(cx, |_, _, cx| {
519 self.ui.update(cx, |modal, cx| {
520 modal.set_status(status.map(|s| s.to_string()), cx);
521 })
522 })
523 .ok();
524 }
525}
526
527pub fn is_connecting_over_ssh(workspace: &Workspace, cx: &App) -> bool {
528 workspace.active_modal::<SshConnectionModal>(cx).is_some()
529}
530
531pub fn connect_over_ssh(
532 unique_identifier: ConnectionIdentifier,
533 connection_options: SshConnectionOptions,
534 ui: Entity<SshPrompt>,
535 window: &mut Window,
536 cx: &mut App,
537) -> Task<Result<Option<Entity<SshRemoteClient>>>> {
538 let window = window.window_handle();
539 let known_password = connection_options.password.clone();
540 let (tx, rx) = oneshot::channel();
541 ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx));
542
543 remote::SshRemoteClient::new(
544 unique_identifier,
545 connection_options,
546 rx,
547 Arc::new(SshClientDelegate {
548 window,
549 ui: ui.downgrade(),
550 known_password,
551 }),
552 cx,
553 )
554}
555
556pub async fn open_ssh_project(
557 connection_options: SshConnectionOptions,
558 paths: Vec<PathBuf>,
559 app_state: Arc<AppState>,
560 open_options: workspace::OpenOptions,
561 cx: &mut AsyncApp,
562) -> Result<()> {
563 let window = if let Some(window) = open_options.replace_window {
564 window
565 } else {
566 let options = cx.update(|cx| (app_state.build_window_options)(None, cx))?;
567 cx.open_window(options, |window, cx| {
568 let project = project::Project::local(
569 app_state.client.clone(),
570 app_state.node_runtime.clone(),
571 app_state.user_store.clone(),
572 app_state.languages.clone(),
573 app_state.debug_adapters.clone(),
574 app_state.fs.clone(),
575 None,
576 cx,
577 );
578 cx.new(|cx| Workspace::new(None, project, app_state.clone(), window, cx))
579 })?
580 };
581
582 loop {
583 let (cancel_tx, cancel_rx) = oneshot::channel();
584 let delegate = window.update(cx, {
585 let connection_options = connection_options.clone();
586 let paths = paths.clone();
587 move |workspace, window, cx| {
588 window.activate_window();
589 workspace.toggle_modal(window, cx, |window, cx| {
590 SshConnectionModal::new(&connection_options, paths, window, cx)
591 });
592
593 let ui = workspace
594 .active_modal::<SshConnectionModal>(cx)?
595 .read(cx)
596 .prompt
597 .clone();
598
599 ui.update(cx, |ui, _cx| {
600 ui.set_cancellation_tx(cancel_tx);
601 });
602
603 Some(Arc::new(SshClientDelegate {
604 window: window.window_handle(),
605 ui: ui.downgrade(),
606 known_password: connection_options.password.clone(),
607 }))
608 }
609 })?;
610
611 let Some(delegate) = delegate else { break };
612
613 let did_open_ssh_project = cx
614 .update(|cx| {
615 workspace::open_ssh_project_with_new_connection(
616 window,
617 connection_options.clone(),
618 cancel_rx,
619 delegate.clone(),
620 app_state.clone(),
621 paths.clone(),
622 cx,
623 )
624 })?
625 .await;
626
627 window
628 .update(cx, |workspace, _, cx| {
629 if let Some(ui) = workspace.active_modal::<SshConnectionModal>(cx) {
630 ui.update(cx, |modal, cx| modal.finished(cx))
631 }
632 })
633 .ok();
634
635 if let Err(e) = did_open_ssh_project {
636 log::error!("Failed to open project: {:?}", e);
637 let response = window
638 .update(cx, |_, window, cx| {
639 window.prompt(
640 PromptLevel::Critical,
641 "Failed to connect over SSH",
642 Some(&e.to_string()),
643 &["Retry", "Ok"],
644 cx,
645 )
646 })?
647 .await;
648
649 if response == Ok(0) {
650 continue;
651 }
652 }
653
654 window
655 .update(cx, |workspace, _, cx| {
656 if let Some(client) = workspace.project().read(cx).ssh_client().clone() {
657 ExtensionStore::global(cx)
658 .update(cx, |store, cx| store.register_ssh_client(client, cx));
659 }
660 })
661 .ok();
662
663 break;
664 }
665
666 // Already showed the error to the user
667 Ok(())
668}