1use std::collections::BTreeSet;
2use std::{path::PathBuf, sync::Arc, time::Duration};
3
4use anyhow::{Context as _, 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 fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {}
130}
131
132pub struct SshPrompt {
133 connection_string: SharedString,
134 nickname: Option<SharedString>,
135 status_message: Option<SharedString>,
136 prompt: Option<(Entity<Markdown>, oneshot::Sender<String>)>,
137 cancellation: Option<oneshot::Sender<()>>,
138 editor: Entity<Editor>,
139}
140
141impl Drop for SshPrompt {
142 fn drop(&mut self) {
143 if let Some(cancel) = self.cancellation.take() {
144 cancel.send(()).ok();
145 }
146 }
147}
148
149pub struct SshConnectionModal {
150 pub(crate) prompt: Entity<SshPrompt>,
151 paths: Vec<PathBuf>,
152 finished: bool,
153}
154
155impl SshPrompt {
156 pub(crate) fn new(
157 connection_options: &SshConnectionOptions,
158 window: &mut Window,
159 cx: &mut Context<Self>,
160 ) -> Self {
161 let connection_string = connection_options.connection_string().into();
162 let nickname = connection_options.nickname.clone().map(|s| s.into());
163
164 Self {
165 connection_string,
166 nickname,
167 editor: cx.new(|cx| Editor::single_line(window, cx)),
168 status_message: None,
169 cancellation: None,
170 prompt: None,
171 }
172 }
173
174 pub fn set_cancellation_tx(&mut self, tx: oneshot::Sender<()>) {
175 self.cancellation = Some(tx);
176 }
177
178 pub fn set_prompt(
179 &mut self,
180 prompt: String,
181 tx: oneshot::Sender<String>,
182 window: &mut Window,
183 cx: &mut Context<Self>,
184 ) {
185 let theme = ThemeSettings::get_global(cx);
186
187 let refinement = TextStyleRefinement {
188 font_family: Some(theme.buffer_font.family.clone()),
189 font_features: Some(FontFeatures::disable_ligatures()),
190 font_size: Some(theme.buffer_font_size(cx).into()),
191 color: Some(cx.theme().colors().editor_foreground),
192 background_color: Some(gpui::transparent_black()),
193 ..Default::default()
194 };
195
196 self.editor.update(cx, |editor, cx| {
197 if prompt.contains("yes/no") {
198 editor.set_masked(false, cx);
199 } else {
200 editor.set_masked(true, cx);
201 }
202 editor.set_text_style_refinement(refinement);
203 editor.set_cursor_shape(CursorShape::Block, cx);
204 });
205
206 let markdown = cx.new(|cx| Markdown::new_text(prompt.into(), cx));
207 self.prompt = Some((markdown, tx));
208 self.status_message.take();
209 window.focus(&self.editor.focus_handle(cx));
210 cx.notify();
211 }
212
213 pub fn set_status(&mut self, status: Option<String>, cx: &mut Context<Self>) {
214 self.status_message = status.map(|s| s.into());
215 cx.notify();
216 }
217
218 pub fn confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
219 if let Some((_, tx)) = self.prompt.take() {
220 self.status_message = Some("Connecting".into());
221 self.editor.update(cx, |editor, cx| {
222 tx.send(editor.text(cx)).ok();
223 editor.clear(window, cx);
224 });
225 }
226 }
227}
228
229impl Render for SshPrompt {
230 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
231 let theme = ThemeSettings::get_global(cx);
232
233 let mut text_style = window.text_style();
234 let refinement = TextStyleRefinement {
235 font_family: Some(theme.buffer_font.family.clone()),
236 font_features: Some(FontFeatures::disable_ligatures()),
237 font_size: Some(theme.buffer_font_size(cx).into()),
238 color: Some(cx.theme().colors().editor_foreground),
239 background_color: Some(gpui::transparent_black()),
240 ..Default::default()
241 };
242
243 text_style.refine(&refinement);
244 let markdown_style = MarkdownStyle {
245 base_text_style: text_style,
246 selection_background_color: cx.theme().players().local().selection,
247 ..Default::default()
248 };
249
250 v_flex()
251 .key_context("PasswordPrompt")
252 .py_2()
253 .px_3()
254 .size_full()
255 .text_buffer(cx)
256 .when_some(self.status_message.clone(), |el, status_message| {
257 el.child(
258 h_flex()
259 .gap_1()
260 .child(
261 Icon::new(IconName::ArrowCircle)
262 .size(IconSize::Medium)
263 .with_animation(
264 "arrow-circle",
265 Animation::new(Duration::from_secs(2)).repeat(),
266 |icon, delta| {
267 icon.transform(Transformation::rotate(percentage(delta)))
268 },
269 ),
270 )
271 .child(
272 div()
273 .text_ellipsis()
274 .overflow_x_hidden()
275 .child(format!("{}…", status_message)),
276 ),
277 )
278 })
279 .when_some(self.prompt.as_ref(), |el, prompt| {
280 el.child(
281 div()
282 .size_full()
283 .overflow_hidden()
284 .child(MarkdownElement::new(prompt.0.clone(), markdown_style))
285 .child(self.editor.clone()),
286 )
287 })
288 }
289}
290
291impl SshConnectionModal {
292 pub(crate) fn new(
293 connection_options: &SshConnectionOptions,
294 paths: Vec<PathBuf>,
295 window: &mut Window,
296 cx: &mut Context<Self>,
297 ) -> Self {
298 Self {
299 prompt: cx.new(|cx| SshPrompt::new(connection_options, window, cx)),
300 finished: false,
301 paths,
302 }
303 }
304
305 fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
306 self.prompt
307 .update(cx, |prompt, cx| prompt.confirm(window, cx))
308 }
309
310 pub fn finished(&mut self, cx: &mut Context<Self>) {
311 self.finished = true;
312 cx.emit(DismissEvent);
313 }
314
315 fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
316 if let Some(tx) = self
317 .prompt
318 .update(cx, |prompt, _cx| prompt.cancellation.take())
319 {
320 tx.send(()).ok();
321 }
322 self.finished(cx);
323 }
324}
325
326pub(crate) struct SshConnectionHeader {
327 pub(crate) connection_string: SharedString,
328 pub(crate) paths: Vec<PathBuf>,
329 pub(crate) nickname: Option<SharedString>,
330}
331
332impl RenderOnce for SshConnectionHeader {
333 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
334 let theme = cx.theme();
335
336 let mut header_color = theme.colors().text;
337 header_color.fade_out(0.96);
338
339 let (main_label, meta_label) = if let Some(nickname) = self.nickname {
340 (nickname, Some(format!("({})", self.connection_string)))
341 } else {
342 (self.connection_string, None)
343 };
344
345 h_flex()
346 .px(DynamicSpacing::Base12.rems(cx))
347 .pt(DynamicSpacing::Base08.rems(cx))
348 .pb(DynamicSpacing::Base04.rems(cx))
349 .rounded_t_sm()
350 .w_full()
351 .gap_1p5()
352 .child(Icon::new(IconName::Server).size(IconSize::XSmall))
353 .child(
354 h_flex()
355 .gap_1()
356 .overflow_x_hidden()
357 .child(
358 div()
359 .max_w_96()
360 .overflow_x_hidden()
361 .text_ellipsis()
362 .child(Headline::new(main_label).size(HeadlineSize::XSmall)),
363 )
364 .children(
365 meta_label.map(|label| {
366 Label::new(label).color(Color::Muted).size(LabelSize::Small)
367 }),
368 )
369 .child(div().overflow_x_hidden().text_ellipsis().children(
370 self.paths.into_iter().map(|path| {
371 Label::new(path.to_string_lossy().to_string())
372 .size(LabelSize::Small)
373 .color(Color::Muted)
374 }),
375 )),
376 )
377 }
378}
379
380impl Render for SshConnectionModal {
381 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
382 let nickname = self.prompt.read(cx).nickname.clone();
383 let connection_string = self.prompt.read(cx).connection_string.clone();
384
385 let theme = cx.theme().clone();
386 let body_color = theme.colors().editor_background;
387
388 v_flex()
389 .elevation_3(cx)
390 .w(rems(34.))
391 .border_1()
392 .border_color(theme.colors().border)
393 .key_context("SshConnectionModal")
394 .track_focus(&self.focus_handle(cx))
395 .on_action(cx.listener(Self::dismiss))
396 .on_action(cx.listener(Self::confirm))
397 .child(
398 SshConnectionHeader {
399 paths: self.paths.clone(),
400 connection_string,
401 nickname,
402 }
403 .render(window, cx),
404 )
405 .child(
406 div()
407 .w_full()
408 .rounded_b_lg()
409 .bg(body_color)
410 .border_t_1()
411 .border_color(theme.colors().border_variant)
412 .child(self.prompt.clone()),
413 )
414 }
415}
416
417impl Focusable for SshConnectionModal {
418 fn focus_handle(&self, cx: &gpui::App) -> gpui::FocusHandle {
419 self.prompt.read(cx).editor.focus_handle(cx)
420 }
421}
422
423impl EventEmitter<DismissEvent> for SshConnectionModal {}
424
425impl ModalView for SshConnectionModal {
426 fn on_before_dismiss(
427 &mut self,
428 _window: &mut Window,
429 _: &mut Context<Self>,
430 ) -> workspace::DismissDecision {
431 return workspace::DismissDecision::Dismiss(self.finished);
432 }
433
434 fn fade_out_background(&self) -> bool {
435 true
436 }
437}
438
439#[derive(Clone)]
440pub struct SshClientDelegate {
441 window: AnyWindowHandle,
442 ui: WeakEntity<SshPrompt>,
443 known_password: Option<String>,
444}
445
446impl remote::SshClientDelegate for SshClientDelegate {
447 fn ask_password(&self, prompt: String, tx: oneshot::Sender<String>, cx: &mut AsyncApp) {
448 let mut known_password = self.known_password.clone();
449 if let Some(password) = known_password.take() {
450 tx.send(password).ok();
451 } else {
452 self.window
453 .update(cx, |_, window, cx| {
454 self.ui.update(cx, |modal, cx| {
455 modal.set_prompt(prompt, tx, window, cx);
456 })
457 })
458 .ok();
459 }
460 }
461
462 fn set_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
463 self.update_status(status, cx)
464 }
465
466 fn download_server_binary_locally(
467 &self,
468 platform: SshPlatform,
469 release_channel: ReleaseChannel,
470 version: Option<SemanticVersion>,
471 cx: &mut AsyncApp,
472 ) -> Task<anyhow::Result<PathBuf>> {
473 cx.spawn(async move |cx| {
474 let binary_path = AutoUpdater::download_remote_server_release(
475 platform.os,
476 platform.arch,
477 release_channel,
478 version,
479 cx,
480 )
481 .await
482 .map_err(|e| {
483 anyhow!(
484 "Failed to download remote server binary (version: {}, os: {}, arch: {}): {}",
485 version
486 .map(|v| format!("{}", v))
487 .unwrap_or("unknown".to_string()),
488 platform.os,
489 platform.arch,
490 e
491 )
492 })?;
493 Ok(binary_path)
494 })
495 }
496
497 fn get_download_params(
498 &self,
499 platform: SshPlatform,
500 release_channel: ReleaseChannel,
501 version: Option<SemanticVersion>,
502 cx: &mut AsyncApp,
503 ) -> Task<Result<Option<(String, String)>>> {
504 cx.spawn(async move |cx| {
505 AutoUpdater::get_remote_server_release_url(
506 platform.os,
507 platform.arch,
508 release_channel,
509 version,
510 cx,
511 )
512 .await
513 })
514 }
515}
516
517impl SshClientDelegate {
518 fn update_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
519 self.window
520 .update(cx, |_, _, cx| {
521 self.ui.update(cx, |modal, cx| {
522 modal.set_status(status.map(|s| s.to_string()), cx);
523 })
524 })
525 .ok();
526 }
527}
528
529pub fn is_connecting_over_ssh(workspace: &Workspace, cx: &App) -> bool {
530 workspace.active_modal::<SshConnectionModal>(cx).is_some()
531}
532
533pub fn connect_over_ssh(
534 unique_identifier: ConnectionIdentifier,
535 connection_options: SshConnectionOptions,
536 ui: Entity<SshPrompt>,
537 window: &mut Window,
538 cx: &mut App,
539) -> Task<Result<Option<Entity<SshRemoteClient>>>> {
540 let window = window.window_handle();
541 let known_password = connection_options.password.clone();
542 let (tx, rx) = oneshot::channel();
543 ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx));
544
545 remote::SshRemoteClient::new(
546 unique_identifier,
547 connection_options,
548 rx,
549 Arc::new(SshClientDelegate {
550 window,
551 ui: ui.downgrade(),
552 known_password,
553 }),
554 cx,
555 )
556}
557
558pub async fn open_ssh_project(
559 connection_options: SshConnectionOptions,
560 paths: Vec<PathBuf>,
561 app_state: Arc<AppState>,
562 open_options: workspace::OpenOptions,
563 cx: &mut AsyncApp,
564) -> Result<()> {
565 let window = if let Some(window) = open_options.replace_window {
566 window
567 } else {
568 let workspace_position = cx
569 .update(|cx| {
570 workspace::ssh_workspace_position_from_db(
571 connection_options.host.clone(),
572 connection_options.port,
573 connection_options.username.clone(),
574 &paths,
575 cx,
576 )
577 })?
578 .await
579 .context("fetching ssh workspace position from db")?;
580
581 let mut options =
582 cx.update(|cx| (app_state.build_window_options)(workspace_position.display, cx))?;
583 options.window_bounds = workspace_position.window_bounds;
584
585 cx.open_window(options, |window, cx| {
586 let project = project::Project::local(
587 app_state.client.clone(),
588 app_state.node_runtime.clone(),
589 app_state.user_store.clone(),
590 app_state.languages.clone(),
591 app_state.fs.clone(),
592 None,
593 cx,
594 );
595 cx.new(|cx| {
596 let mut workspace = Workspace::new(None, project, app_state.clone(), window, cx);
597 workspace.centered_layout = workspace_position.centered_layout;
598 workspace
599 })
600 })?
601 };
602
603 loop {
604 let (cancel_tx, cancel_rx) = oneshot::channel();
605 let delegate = window.update(cx, {
606 let connection_options = connection_options.clone();
607 let paths = paths.clone();
608 move |workspace, window, cx| {
609 window.activate_window();
610 workspace.toggle_modal(window, cx, |window, cx| {
611 SshConnectionModal::new(&connection_options, paths, window, cx)
612 });
613
614 let ui = workspace
615 .active_modal::<SshConnectionModal>(cx)?
616 .read(cx)
617 .prompt
618 .clone();
619
620 ui.update(cx, |ui, _cx| {
621 ui.set_cancellation_tx(cancel_tx);
622 });
623
624 Some(Arc::new(SshClientDelegate {
625 window: window.window_handle(),
626 ui: ui.downgrade(),
627 known_password: connection_options.password.clone(),
628 }))
629 }
630 })?;
631
632 let Some(delegate) = delegate else { break };
633
634 let did_open_ssh_project = cx
635 .update(|cx| {
636 workspace::open_ssh_project_with_new_connection(
637 window,
638 connection_options.clone(),
639 cancel_rx,
640 delegate.clone(),
641 app_state.clone(),
642 paths.clone(),
643 cx,
644 )
645 })?
646 .await;
647
648 window
649 .update(cx, |workspace, _, cx| {
650 if let Some(ui) = workspace.active_modal::<SshConnectionModal>(cx) {
651 ui.update(cx, |modal, cx| modal.finished(cx))
652 }
653 })
654 .ok();
655
656 if let Err(e) = did_open_ssh_project {
657 log::error!("Failed to open project: {e:?}");
658 let response = window
659 .update(cx, |_, window, cx| {
660 window.prompt(
661 PromptLevel::Critical,
662 "Failed to connect over SSH",
663 Some(&e.to_string()),
664 &["Retry", "Ok"],
665 cx,
666 )
667 })?
668 .await;
669
670 if response == Ok(0) {
671 continue;
672 }
673 }
674
675 window
676 .update(cx, |workspace, _, cx| {
677 if let Some(client) = workspace.project().read(cx).ssh_client().clone() {
678 ExtensionStore::global(cx)
679 .update(cx, |store, cx| store.register_ssh_client(client, cx));
680 }
681 })
682 .ok();
683
684 break;
685 }
686
687 // Already showed the error to the user
688 Ok(())
689}