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