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