1use std::{path::PathBuf, sync::Arc, time::Duration};
2
3use anyhow::{anyhow, Result};
4use auto_update::AutoUpdater;
5use editor::Editor;
6use futures::channel::oneshot;
7use gpui::{
8 percentage, Animation, AnimationExt, AnyWindowHandle, AsyncAppContext, DismissEvent,
9 EventEmitter, FocusableView, ParentElement as _, PromptLevel, Render, SemanticVersion,
10 SharedString, Task, TextStyleRefinement, Transformation, View, WeakView,
11};
12use gpui::{AppContext, Model};
13
14use language::CursorShape;
15use markdown::{Markdown, MarkdownStyle};
16use release_channel::{AppVersion, ReleaseChannel};
17use remote::ssh_session::ServerBinary;
18use remote::{SshConnectionOptions, SshPlatform, SshRemoteClient};
19use schemars::JsonSchema;
20use serde::{Deserialize, Serialize};
21use settings::{Settings, SettingsSources};
22use theme::ThemeSettings;
23use ui::{
24 prelude::*, ActiveTheme, Color, Icon, IconName, IconSize, InteractiveElement, IntoElement,
25 Label, LabelCommon, Styled, ViewContext, VisualContext, WindowContext,
26};
27use workspace::{AppState, ModalView, Workspace};
28
29#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
30pub struct RemoteServerSettings {
31 pub download_on_host: Option<bool>,
32}
33
34#[derive(Deserialize)]
35pub struct SshSettings {
36 pub ssh_connections: Option<Vec<SshConnection>>,
37 pub remote_server: Option<RemoteServerSettings>,
38}
39
40impl SshSettings {
41 pub fn ssh_connections(&self) -> impl Iterator<Item = SshConnection> {
42 self.ssh_connections.clone().into_iter().flatten()
43 }
44
45 pub fn args_for(
46 &self,
47 host: &str,
48 port: Option<u16>,
49 user: &Option<String>,
50 ) -> Option<Vec<String>> {
51 self.ssh_connections()
52 .filter_map(|conn| {
53 if conn.host == host && &conn.username == user && conn.port == port {
54 Some(conn.args)
55 } else {
56 None
57 }
58 })
59 .next()
60 }
61
62 pub fn nickname_for(
63 &self,
64 host: &str,
65 port: Option<u16>,
66 user: &Option<String>,
67 ) -> Option<SharedString> {
68 self.ssh_connections()
69 .filter_map(|conn| {
70 if conn.host == host && &conn.username == user && conn.port == port {
71 Some(conn.nickname)
72 } else {
73 None
74 }
75 })
76 .next()
77 .flatten()
78 }
79}
80
81#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
82pub struct SshConnection {
83 pub host: SharedString,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 pub username: Option<String>,
86 #[serde(skip_serializing_if = "Option::is_none")]
87 pub port: Option<u16>,
88 pub projects: Vec<SshProject>,
89 /// Name to use for this server in UI.
90 #[serde(skip_serializing_if = "Option::is_none")]
91 pub nickname: Option<SharedString>,
92 #[serde(skip_serializing_if = "Vec::is_empty")]
93 #[serde(default)]
94 pub args: Vec<String>,
95}
96
97impl From<SshConnection> for SshConnectionOptions {
98 fn from(val: SshConnection) -> Self {
99 SshConnectionOptions {
100 host: val.host.into(),
101 username: val.username,
102 port: val.port,
103 password: None,
104 args: Some(val.args),
105 }
106 }
107}
108
109#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
110pub struct SshProject {
111 pub paths: Vec<String>,
112}
113
114#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
115pub struct RemoteSettingsContent {
116 pub ssh_connections: Option<Vec<SshConnection>>,
117 pub remote_server: Option<RemoteServerSettings>,
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 AppContext) -> 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<(View<Markdown>, oneshot::Sender<Result<String>>)>,
135 cancellation: Option<oneshot::Sender<()>>,
136 editor: View<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: View<SshPrompt>,
149 paths: Vec<PathBuf>,
150 finished: bool,
151}
152
153impl SshPrompt {
154 pub(crate) fn new(
155 connection_options: &SshConnectionOptions,
156 nickname: Option<SharedString>,
157 cx: &mut ViewContext<Self>,
158 ) -> Self {
159 let connection_string = connection_options.connection_string().into();
160
161 Self {
162 connection_string,
163 nickname,
164 editor: cx.new_view(Editor::single_line),
165 status_message: None,
166 cancellation: None,
167 prompt: None,
168 }
169 }
170
171 pub fn set_cancellation_tx(&mut self, tx: oneshot::Sender<()>) {
172 self.cancellation = Some(tx);
173 }
174
175 pub fn set_prompt(
176 &mut self,
177 prompt: String,
178 tx: oneshot::Sender<Result<String>>,
179 cx: &mut ViewContext<Self>,
180 ) {
181 let theme = ThemeSettings::get_global(cx);
182
183 let mut text_style = cx.text_style();
184 let refinement = TextStyleRefinement {
185 font_family: Some(theme.buffer_font.family.clone()),
186 font_size: Some(theme.buffer_font_size.into()),
187 color: Some(cx.theme().colors().editor_foreground),
188 background_color: Some(gpui::transparent_black()),
189 ..Default::default()
190 };
191
192 text_style.refine(&refinement);
193 self.editor.update(cx, |editor, cx| {
194 if prompt.contains("yes/no") {
195 editor.set_masked(false, cx);
196 } else {
197 editor.set_masked(true, cx);
198 }
199 editor.set_text_style_refinement(refinement);
200 editor.set_cursor_shape(CursorShape::Block, cx);
201 });
202 let markdown_style = MarkdownStyle {
203 base_text_style: text_style,
204 selection_background_color: cx.theme().players().local().selection,
205 ..Default::default()
206 };
207 let markdown = cx.new_view(|cx| Markdown::new_text(prompt, markdown_style, None, cx, None));
208 self.prompt = Some((markdown, tx));
209 self.status_message.take();
210 cx.focus_view(&self.editor);
211 cx.notify();
212 }
213
214 pub fn set_status(&mut self, status: Option<String>, cx: &mut ViewContext<Self>) {
215 self.status_message = status.map(|s| s.into());
216 cx.notify();
217 }
218
219 pub fn confirm(&mut self, cx: &mut ViewContext<Self>) {
220 if let Some((_, tx)) = self.prompt.take() {
221 self.status_message = Some("Connecting".into());
222 self.editor.update(cx, |editor, cx| {
223 tx.send(Ok(editor.text(cx))).ok();
224 editor.clear(cx);
225 });
226 }
227 }
228}
229
230impl Render for SshPrompt {
231 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
232 let cx = cx.window_context();
233
234 v_flex()
235 .key_context("PasswordPrompt")
236 .py_2()
237 .px_3()
238 .size_full()
239 .text_buffer(cx)
240 .when_some(self.status_message.clone(), |el, status_message| {
241 el.child(
242 h_flex()
243 .gap_1()
244 .child(
245 Icon::new(IconName::ArrowCircle)
246 .size(IconSize::Medium)
247 .with_animation(
248 "arrow-circle",
249 Animation::new(Duration::from_secs(2)).repeat(),
250 |icon, delta| {
251 icon.transform(Transformation::rotate(percentage(delta)))
252 },
253 ),
254 )
255 .child(
256 div()
257 .text_ellipsis()
258 .overflow_x_hidden()
259 .child(format!("{}…", status_message)),
260 ),
261 )
262 })
263 .when_some(self.prompt.as_ref(), |el, prompt| {
264 el.child(
265 div()
266 .size_full()
267 .overflow_hidden()
268 .child(prompt.0.clone())
269 .child(self.editor.clone()),
270 )
271 })
272 }
273}
274
275impl SshConnectionModal {
276 pub(crate) fn new(
277 connection_options: &SshConnectionOptions,
278 paths: Vec<PathBuf>,
279 nickname: Option<SharedString>,
280 cx: &mut ViewContext<Self>,
281 ) -> Self {
282 Self {
283 prompt: cx.new_view(|cx| SshPrompt::new(connection_options, nickname, cx)),
284 finished: false,
285 paths,
286 }
287 }
288
289 fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
290 self.prompt.update(cx, |prompt, cx| prompt.confirm(cx))
291 }
292
293 pub fn finished(&mut self, cx: &mut ViewContext<Self>) {
294 self.finished = true;
295 cx.emit(DismissEvent);
296 }
297
298 fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
299 if let Some(tx) = self
300 .prompt
301 .update(cx, |prompt, _cx| prompt.cancellation.take())
302 {
303 tx.send(()).ok();
304 }
305 self.finished(cx);
306 }
307}
308
309pub(crate) struct SshConnectionHeader {
310 pub(crate) connection_string: SharedString,
311 pub(crate) paths: Vec<PathBuf>,
312 pub(crate) nickname: Option<SharedString>,
313}
314
315impl RenderOnce for SshConnectionHeader {
316 fn render(self, cx: &mut WindowContext) -> impl IntoElement {
317 let theme = cx.theme();
318
319 let mut header_color = theme.colors().text;
320 header_color.fade_out(0.96);
321
322 let (main_label, meta_label) = if let Some(nickname) = self.nickname {
323 (nickname, Some(format!("({})", self.connection_string)))
324 } else {
325 (self.connection_string, None)
326 };
327
328 h_flex()
329 .px(Spacing::XLarge.rems(cx))
330 .pt(Spacing::Large.rems(cx))
331 .pb(Spacing::Small.rems(cx))
332 .rounded_t_md()
333 .w_full()
334 .gap_1p5()
335 .child(Icon::new(IconName::Server).size(IconSize::XSmall))
336 .child(
337 h_flex()
338 .gap_1()
339 .child(Headline::new(main_label).size(HeadlineSize::XSmall))
340 .children(
341 meta_label.map(|label| {
342 Label::new(label).color(Color::Muted).size(LabelSize::Small)
343 }),
344 )
345 .children(self.paths.into_iter().map(|path| {
346 Label::new(path.to_string_lossy().to_string())
347 .size(LabelSize::Small)
348 .color(Color::Muted)
349 })),
350 )
351 }
352}
353
354impl Render for SshConnectionModal {
355 fn render(&mut self, cx: &mut ui::ViewContext<Self>) -> impl ui::IntoElement {
356 let nickname = self.prompt.read(cx).nickname.clone();
357 let connection_string = self.prompt.read(cx).connection_string.clone();
358
359 let theme = cx.theme().clone();
360 let body_color = theme.colors().editor_background;
361
362 v_flex()
363 .elevation_3(cx)
364 .w(rems(34.))
365 .border_1()
366 .border_color(theme.colors().border)
367 .key_context("SshConnectionModal")
368 .track_focus(&self.focus_handle(cx))
369 .on_action(cx.listener(Self::dismiss))
370 .on_action(cx.listener(Self::confirm))
371 .child(
372 SshConnectionHeader {
373 paths: self.paths.clone(),
374 connection_string,
375 nickname,
376 }
377 .render(cx),
378 )
379 .child(
380 div()
381 .w_full()
382 .rounded_b_lg()
383 .bg(body_color)
384 .border_t_1()
385 .border_color(theme.colors().border_variant)
386 .child(self.prompt.clone()),
387 )
388 }
389}
390
391impl FocusableView for SshConnectionModal {
392 fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
393 self.prompt.read(cx).editor.focus_handle(cx)
394 }
395}
396
397impl EventEmitter<DismissEvent> for SshConnectionModal {}
398
399impl ModalView for SshConnectionModal {
400 fn on_before_dismiss(&mut self, _: &mut ViewContext<Self>) -> workspace::DismissDecision {
401 return workspace::DismissDecision::Dismiss(self.finished);
402 }
403
404 fn fade_out_background(&self) -> bool {
405 true
406 }
407}
408
409#[derive(Clone)]
410pub struct SshClientDelegate {
411 window: AnyWindowHandle,
412 ui: WeakView<SshPrompt>,
413 known_password: Option<String>,
414}
415
416impl remote::SshClientDelegate for SshClientDelegate {
417 fn ask_password(
418 &self,
419 prompt: String,
420 cx: &mut AsyncAppContext,
421 ) -> oneshot::Receiver<Result<String>> {
422 let (tx, rx) = oneshot::channel();
423 let mut known_password = self.known_password.clone();
424 if let Some(password) = known_password.take() {
425 tx.send(Ok(password)).ok();
426 } else {
427 self.window
428 .update(cx, |_, cx| {
429 self.ui.update(cx, |modal, cx| {
430 modal.set_prompt(prompt, tx, cx);
431 })
432 })
433 .ok();
434 }
435 rx
436 }
437
438 fn set_status(&self, status: Option<&str>, cx: &mut AsyncAppContext) {
439 self.update_status(status, cx)
440 }
441
442 fn get_server_binary(
443 &self,
444 platform: SshPlatform,
445 cx: &mut AsyncAppContext,
446 ) -> oneshot::Receiver<Result<(ServerBinary, SemanticVersion)>> {
447 let (tx, rx) = oneshot::channel();
448 let this = self.clone();
449 cx.spawn(|mut cx| async move {
450 tx.send(this.get_server_binary_impl(platform, &mut cx).await)
451 .ok();
452 })
453 .detach();
454 rx
455 }
456
457 fn remote_server_binary_path(
458 &self,
459 platform: SshPlatform,
460 cx: &mut AsyncAppContext,
461 ) -> Result<PathBuf> {
462 let release_channel = cx.update(|cx| ReleaseChannel::global(cx))?;
463 Ok(paths::remote_server_dir_relative().join(format!(
464 "zed-remote-server-{}-{}-{}",
465 release_channel.dev_name(),
466 platform.os,
467 platform.arch
468 )))
469 }
470}
471
472impl SshClientDelegate {
473 fn update_status(&self, status: Option<&str>, cx: &mut AsyncAppContext) {
474 self.window
475 .update(cx, |_, cx| {
476 self.ui.update(cx, |modal, cx| {
477 modal.set_status(status.map(|s| s.to_string()), cx);
478 })
479 })
480 .ok();
481 }
482
483 async fn get_server_binary_impl(
484 &self,
485 platform: SshPlatform,
486 cx: &mut AsyncAppContext,
487 ) -> Result<(ServerBinary, SemanticVersion)> {
488 let (version, release_channel, download_binary_on_host) = cx.update(|cx| {
489 let version = AppVersion::global(cx);
490 let channel = ReleaseChannel::global(cx);
491
492 let ssh_settings = SshSettings::get_global(cx);
493 let download_binary_on_host = ssh_settings
494 .remote_server
495 .as_ref()
496 .and_then(|server| server.download_on_host)
497 .unwrap_or(false);
498 (version, channel, download_binary_on_host)
499 })?;
500
501 // In dev mode, build the remote server binary from source
502 #[cfg(debug_assertions)]
503 if release_channel == ReleaseChannel::Dev {
504 let result = self.build_local(cx, platform, version).await?;
505 // Fall through to a remote binary if we're not able to compile a local binary
506 if let Some((path, version)) = result {
507 return Ok((ServerBinary::LocalBinary(path), version));
508 }
509 }
510
511 if download_binary_on_host {
512 let (request_url, request_body) = AutoUpdater::get_latest_remote_server_release_url(
513 platform.os,
514 platform.arch,
515 release_channel,
516 cx,
517 )
518 .await
519 .map_err(|e| {
520 anyhow!(
521 "failed to get remote server binary download url (os: {}, arch: {}): {}",
522 platform.os,
523 platform.arch,
524 e
525 )
526 })?;
527
528 Ok((
529 ServerBinary::ReleaseUrl {
530 url: request_url,
531 body: request_body,
532 },
533 version,
534 ))
535 } else {
536 self.update_status(Some("checking for latest version of remote server"), cx);
537 let binary_path = AutoUpdater::get_latest_remote_server_release(
538 platform.os,
539 platform.arch,
540 release_channel,
541 cx,
542 )
543 .await
544 .map_err(|e| {
545 anyhow!(
546 "failed to download remote server binary (os: {}, arch: {}): {}",
547 platform.os,
548 platform.arch,
549 e
550 )
551 })?;
552
553 Ok((ServerBinary::LocalBinary(binary_path), version))
554 }
555 }
556
557 #[cfg(debug_assertions)]
558 async fn build_local(
559 &self,
560 cx: &mut AsyncAppContext,
561 platform: SshPlatform,
562 version: gpui::SemanticVersion,
563 ) -> Result<Option<(PathBuf, gpui::SemanticVersion)>> {
564 use smol::process::{Command, Stdio};
565
566 async fn run_cmd(command: &mut Command) -> Result<()> {
567 let output = command
568 .kill_on_drop(true)
569 .stderr(Stdio::inherit())
570 .output()
571 .await?;
572 if !output.status.success() {
573 Err(anyhow!("failed to run command: {:?}", command))?;
574 }
575 Ok(())
576 }
577
578 if platform.arch == std::env::consts::ARCH && platform.os == std::env::consts::OS {
579 self.update_status(Some("Building remote server binary from source"), cx);
580 log::info!("building remote server binary from source");
581 run_cmd(Command::new("cargo").args([
582 "build",
583 "--package",
584 "remote_server",
585 "--features",
586 "debug-embed",
587 "--target-dir",
588 "target/remote_server",
589 ]))
590 .await?;
591
592 self.update_status(Some("Compressing binary"), cx);
593
594 run_cmd(Command::new("gzip").args([
595 "-9",
596 "-f",
597 "target/remote_server/debug/remote_server",
598 ]))
599 .await?;
600
601 let path = std::env::current_dir()?.join("target/remote_server/debug/remote_server.gz");
602 return Ok(Some((path, version)));
603 } else if let Some(triple) = platform.triple() {
604 smol::fs::create_dir_all("target/remote_server").await?;
605
606 self.update_status(Some("Installing cross.rs for cross-compilation"), cx);
607 log::info!("installing cross");
608 run_cmd(Command::new("cargo").args([
609 "install",
610 "cross",
611 "--git",
612 "https://github.com/cross-rs/cross",
613 ]))
614 .await?;
615
616 self.update_status(
617 Some(&format!(
618 "Building remote server binary from source for {}",
619 &triple
620 )),
621 cx,
622 );
623 log::info!("building remote server binary from source for {}", &triple);
624 run_cmd(
625 Command::new("cross")
626 .args([
627 "build",
628 "--package",
629 "remote_server",
630 "--features",
631 "debug-embed",
632 "--target-dir",
633 "target/remote_server",
634 "--target",
635 &triple,
636 ])
637 .env(
638 "CROSS_CONTAINER_OPTS",
639 "--mount type=bind,src=./target,dst=/app/target",
640 ),
641 )
642 .await?;
643
644 self.update_status(Some("Compressing binary"), cx);
645
646 run_cmd(Command::new("gzip").args([
647 "-9",
648 "-f",
649 &format!("target/remote_server/{}/debug/remote_server", triple),
650 ]))
651 .await?;
652
653 let path = std::env::current_dir()?.join(format!(
654 "target/remote_server/{}/debug/remote_server.gz",
655 triple
656 ));
657
658 return Ok(Some((path, version)));
659 } else {
660 return Ok(None);
661 }
662 }
663}
664
665pub fn connect_over_ssh(
666 unique_identifier: String,
667 connection_options: SshConnectionOptions,
668 ui: View<SshPrompt>,
669 cx: &mut WindowContext,
670) -> Task<Result<Option<Model<SshRemoteClient>>>> {
671 let window = cx.window_handle();
672 let known_password = connection_options.password.clone();
673 let (tx, rx) = oneshot::channel();
674 ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx));
675
676 remote::SshRemoteClient::new(
677 unique_identifier,
678 connection_options,
679 rx,
680 Arc::new(SshClientDelegate {
681 window,
682 ui: ui.downgrade(),
683 known_password,
684 }),
685 cx,
686 )
687}
688
689pub async fn open_ssh_project(
690 connection_options: SshConnectionOptions,
691 paths: Vec<PathBuf>,
692 app_state: Arc<AppState>,
693 open_options: workspace::OpenOptions,
694 nickname: Option<SharedString>,
695 cx: &mut AsyncAppContext,
696) -> Result<()> {
697 let window = if let Some(window) = open_options.replace_window {
698 window
699 } else {
700 let options = cx.update(|cx| (app_state.build_window_options)(None, cx))?;
701 cx.open_window(options, |cx| {
702 let project = project::Project::local(
703 app_state.client.clone(),
704 app_state.node_runtime.clone(),
705 app_state.user_store.clone(),
706 app_state.languages.clone(),
707 app_state.fs.clone(),
708 None,
709 cx,
710 );
711 cx.new_view(|cx| Workspace::new(None, project, app_state.clone(), cx))
712 })?
713 };
714
715 loop {
716 let (cancel_tx, cancel_rx) = oneshot::channel();
717 let delegate = window.update(cx, {
718 let connection_options = connection_options.clone();
719 let nickname = nickname.clone();
720 let paths = paths.clone();
721 move |workspace, cx| {
722 cx.activate_window();
723 workspace.toggle_modal(cx, |cx| {
724 SshConnectionModal::new(&connection_options, paths, nickname.clone(), cx)
725 });
726
727 let ui = workspace
728 .active_modal::<SshConnectionModal>(cx)?
729 .read(cx)
730 .prompt
731 .clone();
732
733 ui.update(cx, |ui, _cx| {
734 ui.set_cancellation_tx(cancel_tx);
735 });
736
737 Some(Arc::new(SshClientDelegate {
738 window: cx.window_handle(),
739 ui: ui.downgrade(),
740 known_password: connection_options.password.clone(),
741 }))
742 }
743 })?;
744
745 let Some(delegate) = delegate else { break };
746
747 let did_open_ssh_project = cx
748 .update(|cx| {
749 workspace::open_ssh_project(
750 window,
751 connection_options.clone(),
752 cancel_rx,
753 delegate.clone(),
754 app_state.clone(),
755 paths.clone(),
756 cx,
757 )
758 })?
759 .await;
760
761 window
762 .update(cx, |workspace, cx| {
763 if let Some(ui) = workspace.active_modal::<SshConnectionModal>(cx) {
764 ui.update(cx, |modal, cx| modal.finished(cx))
765 }
766 })
767 .ok();
768
769 if let Err(e) = did_open_ssh_project {
770 log::error!("Failed to open project: {:?}", e);
771 let response = window
772 .update(cx, |_, cx| {
773 cx.prompt(
774 PromptLevel::Critical,
775 "Failed to connect over SSH",
776 Some(&e.to_string()),
777 &["Retry", "Ok"],
778 )
779 })?
780 .await;
781
782 if response == Ok(0) {
783 continue;
784 }
785 }
786
787 break;
788 }
789
790 // Already showed the error to the user
791 Ok(())
792}