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 .overflow_x_hidden()
340 .child(
341 div()
342 .max_w_96()
343 .overflow_x_hidden()
344 .text_ellipsis()
345 .child(Headline::new(main_label).size(HeadlineSize::XSmall)),
346 )
347 .children(
348 meta_label.map(|label| {
349 Label::new(label).color(Color::Muted).size(LabelSize::Small)
350 }),
351 )
352 .child(div().overflow_x_hidden().text_ellipsis().children(
353 self.paths.into_iter().map(|path| {
354 Label::new(path.to_string_lossy().to_string())
355 .size(LabelSize::Small)
356 .color(Color::Muted)
357 }),
358 )),
359 )
360 }
361}
362
363impl Render for SshConnectionModal {
364 fn render(&mut self, cx: &mut ui::ViewContext<Self>) -> impl ui::IntoElement {
365 let nickname = self.prompt.read(cx).nickname.clone();
366 let connection_string = self.prompt.read(cx).connection_string.clone();
367
368 let theme = cx.theme().clone();
369 let body_color = theme.colors().editor_background;
370
371 v_flex()
372 .elevation_3(cx)
373 .w(rems(34.))
374 .border_1()
375 .border_color(theme.colors().border)
376 .key_context("SshConnectionModal")
377 .track_focus(&self.focus_handle(cx))
378 .on_action(cx.listener(Self::dismiss))
379 .on_action(cx.listener(Self::confirm))
380 .child(
381 SshConnectionHeader {
382 paths: self.paths.clone(),
383 connection_string,
384 nickname,
385 }
386 .render(cx),
387 )
388 .child(
389 div()
390 .w_full()
391 .rounded_b_lg()
392 .bg(body_color)
393 .border_t_1()
394 .border_color(theme.colors().border_variant)
395 .child(self.prompt.clone()),
396 )
397 }
398}
399
400impl FocusableView for SshConnectionModal {
401 fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
402 self.prompt.read(cx).editor.focus_handle(cx)
403 }
404}
405
406impl EventEmitter<DismissEvent> for SshConnectionModal {}
407
408impl ModalView for SshConnectionModal {
409 fn on_before_dismiss(&mut self, _: &mut ViewContext<Self>) -> workspace::DismissDecision {
410 return workspace::DismissDecision::Dismiss(self.finished);
411 }
412
413 fn fade_out_background(&self) -> bool {
414 true
415 }
416}
417
418#[derive(Clone)]
419pub struct SshClientDelegate {
420 window: AnyWindowHandle,
421 ui: WeakView<SshPrompt>,
422 known_password: Option<String>,
423}
424
425impl remote::SshClientDelegate for SshClientDelegate {
426 fn ask_password(
427 &self,
428 prompt: String,
429 cx: &mut AsyncAppContext,
430 ) -> oneshot::Receiver<Result<String>> {
431 let (tx, rx) = oneshot::channel();
432 let mut known_password = self.known_password.clone();
433 if let Some(password) = known_password.take() {
434 tx.send(Ok(password)).ok();
435 } else {
436 self.window
437 .update(cx, |_, cx| {
438 self.ui.update(cx, |modal, cx| {
439 modal.set_prompt(prompt, tx, cx);
440 })
441 })
442 .ok();
443 }
444 rx
445 }
446
447 fn set_status(&self, status: Option<&str>, cx: &mut AsyncAppContext) {
448 self.update_status(status, cx)
449 }
450
451 fn get_server_binary(
452 &self,
453 platform: SshPlatform,
454 cx: &mut AsyncAppContext,
455 ) -> oneshot::Receiver<Result<(ServerBinary, SemanticVersion)>> {
456 let (tx, rx) = oneshot::channel();
457 let this = self.clone();
458 cx.spawn(|mut cx| async move {
459 tx.send(this.get_server_binary_impl(platform, &mut cx).await)
460 .ok();
461 })
462 .detach();
463 rx
464 }
465
466 fn remote_server_binary_path(
467 &self,
468 platform: SshPlatform,
469 cx: &mut AsyncAppContext,
470 ) -> Result<PathBuf> {
471 let release_channel = cx.update(|cx| ReleaseChannel::global(cx))?;
472 Ok(paths::remote_server_dir_relative().join(format!(
473 "zed-remote-server-{}-{}-{}",
474 release_channel.dev_name(),
475 platform.os,
476 platform.arch
477 )))
478 }
479}
480
481impl SshClientDelegate {
482 fn update_status(&self, status: Option<&str>, cx: &mut AsyncAppContext) {
483 self.window
484 .update(cx, |_, cx| {
485 self.ui.update(cx, |modal, cx| {
486 modal.set_status(status.map(|s| s.to_string()), cx);
487 })
488 })
489 .ok();
490 }
491
492 async fn get_server_binary_impl(
493 &self,
494 platform: SshPlatform,
495 cx: &mut AsyncAppContext,
496 ) -> Result<(ServerBinary, SemanticVersion)> {
497 let (version, release_channel, download_binary_on_host) = cx.update(|cx| {
498 let version = AppVersion::global(cx);
499 let channel = ReleaseChannel::global(cx);
500
501 let ssh_settings = SshSettings::get_global(cx);
502 let download_binary_on_host = ssh_settings
503 .remote_server
504 .as_ref()
505 .and_then(|server| server.download_on_host)
506 .unwrap_or(false);
507 (version, channel, download_binary_on_host)
508 })?;
509
510 // In dev mode, build the remote server binary from source
511 #[cfg(debug_assertions)]
512 if release_channel == ReleaseChannel::Dev {
513 let result = self.build_local(cx, platform, version).await?;
514 // Fall through to a remote binary if we're not able to compile a local binary
515 if let Some((path, version)) = result {
516 return Ok((ServerBinary::LocalBinary(path), version));
517 }
518 }
519
520 if download_binary_on_host {
521 let (request_url, request_body) = AutoUpdater::get_latest_remote_server_release_url(
522 platform.os,
523 platform.arch,
524 release_channel,
525 cx,
526 )
527 .await
528 .map_err(|e| {
529 anyhow!(
530 "failed to get remote server binary download url (os: {}, arch: {}): {}",
531 platform.os,
532 platform.arch,
533 e
534 )
535 })?;
536
537 Ok((
538 ServerBinary::ReleaseUrl {
539 url: request_url,
540 body: request_body,
541 },
542 version,
543 ))
544 } else {
545 self.update_status(Some("checking for latest version of remote server"), cx);
546 let binary_path = AutoUpdater::get_latest_remote_server_release(
547 platform.os,
548 platform.arch,
549 release_channel,
550 cx,
551 )
552 .await
553 .map_err(|e| {
554 anyhow!(
555 "failed to download remote server binary (os: {}, arch: {}): {}",
556 platform.os,
557 platform.arch,
558 e
559 )
560 })?;
561
562 Ok((ServerBinary::LocalBinary(binary_path), version))
563 }
564 }
565
566 #[cfg(debug_assertions)]
567 async fn build_local(
568 &self,
569 cx: &mut AsyncAppContext,
570 platform: SshPlatform,
571 version: gpui::SemanticVersion,
572 ) -> Result<Option<(PathBuf, gpui::SemanticVersion)>> {
573 use smol::process::{Command, Stdio};
574
575 async fn run_cmd(command: &mut Command) -> Result<()> {
576 let output = command
577 .kill_on_drop(true)
578 .stderr(Stdio::inherit())
579 .output()
580 .await?;
581 if !output.status.success() {
582 Err(anyhow!("failed to run command: {:?}", command))?;
583 }
584 Ok(())
585 }
586
587 if platform.arch == std::env::consts::ARCH && platform.os == std::env::consts::OS {
588 self.update_status(Some("Building remote server binary from source"), cx);
589 log::info!("building remote server binary from source");
590 run_cmd(Command::new("cargo").args([
591 "build",
592 "--package",
593 "remote_server",
594 "--features",
595 "debug-embed",
596 "--target-dir",
597 "target/remote_server",
598 ]))
599 .await?;
600
601 self.update_status(Some("Compressing binary"), cx);
602
603 run_cmd(Command::new("gzip").args([
604 "-9",
605 "-f",
606 "target/remote_server/debug/remote_server",
607 ]))
608 .await?;
609
610 let path = std::env::current_dir()?.join("target/remote_server/debug/remote_server.gz");
611 return Ok(Some((path, version)));
612 } else if let Some(triple) = platform.triple() {
613 smol::fs::create_dir_all("target/remote_server").await?;
614
615 self.update_status(Some("Installing cross.rs for cross-compilation"), cx);
616 log::info!("installing cross");
617 run_cmd(Command::new("cargo").args([
618 "install",
619 "cross",
620 "--git",
621 "https://github.com/cross-rs/cross",
622 ]))
623 .await?;
624
625 self.update_status(
626 Some(&format!(
627 "Building remote server binary from source for {}",
628 &triple
629 )),
630 cx,
631 );
632 log::info!("building remote server binary from source for {}", &triple);
633 run_cmd(
634 Command::new("cross")
635 .args([
636 "build",
637 "--package",
638 "remote_server",
639 "--features",
640 "debug-embed",
641 "--target-dir",
642 "target/remote_server",
643 "--target",
644 &triple,
645 ])
646 .env(
647 "CROSS_CONTAINER_OPTS",
648 "--mount type=bind,src=./target,dst=/app/target",
649 ),
650 )
651 .await?;
652
653 self.update_status(Some("Compressing binary"), cx);
654
655 run_cmd(Command::new("gzip").args([
656 "-9",
657 "-f",
658 &format!("target/remote_server/{}/debug/remote_server", triple),
659 ]))
660 .await?;
661
662 let path = std::env::current_dir()?.join(format!(
663 "target/remote_server/{}/debug/remote_server.gz",
664 triple
665 ));
666
667 return Ok(Some((path, version)));
668 } else {
669 return Ok(None);
670 }
671 }
672}
673
674pub fn connect_over_ssh(
675 unique_identifier: String,
676 connection_options: SshConnectionOptions,
677 ui: View<SshPrompt>,
678 cx: &mut WindowContext,
679) -> Task<Result<Option<Model<SshRemoteClient>>>> {
680 let window = cx.window_handle();
681 let known_password = connection_options.password.clone();
682 let (tx, rx) = oneshot::channel();
683 ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx));
684
685 remote::SshRemoteClient::new(
686 unique_identifier,
687 connection_options,
688 rx,
689 Arc::new(SshClientDelegate {
690 window,
691 ui: ui.downgrade(),
692 known_password,
693 }),
694 cx,
695 )
696}
697
698pub async fn open_ssh_project(
699 connection_options: SshConnectionOptions,
700 paths: Vec<PathBuf>,
701 app_state: Arc<AppState>,
702 open_options: workspace::OpenOptions,
703 nickname: Option<SharedString>,
704 cx: &mut AsyncAppContext,
705) -> Result<()> {
706 let window = if let Some(window) = open_options.replace_window {
707 window
708 } else {
709 let options = cx.update(|cx| (app_state.build_window_options)(None, cx))?;
710 cx.open_window(options, |cx| {
711 let project = project::Project::local(
712 app_state.client.clone(),
713 app_state.node_runtime.clone(),
714 app_state.user_store.clone(),
715 app_state.languages.clone(),
716 app_state.fs.clone(),
717 None,
718 cx,
719 );
720 cx.new_view(|cx| Workspace::new(None, project, app_state.clone(), cx))
721 })?
722 };
723
724 loop {
725 let (cancel_tx, cancel_rx) = oneshot::channel();
726 let delegate = window.update(cx, {
727 let connection_options = connection_options.clone();
728 let nickname = nickname.clone();
729 let paths = paths.clone();
730 move |workspace, cx| {
731 cx.activate_window();
732 workspace.toggle_modal(cx, |cx| {
733 SshConnectionModal::new(&connection_options, paths, nickname.clone(), cx)
734 });
735
736 let ui = workspace
737 .active_modal::<SshConnectionModal>(cx)?
738 .read(cx)
739 .prompt
740 .clone();
741
742 ui.update(cx, |ui, _cx| {
743 ui.set_cancellation_tx(cancel_tx);
744 });
745
746 Some(Arc::new(SshClientDelegate {
747 window: cx.window_handle(),
748 ui: ui.downgrade(),
749 known_password: connection_options.password.clone(),
750 }))
751 }
752 })?;
753
754 let Some(delegate) = delegate else { break };
755
756 let did_open_ssh_project = cx
757 .update(|cx| {
758 workspace::open_ssh_project(
759 window,
760 connection_options.clone(),
761 cancel_rx,
762 delegate.clone(),
763 app_state.clone(),
764 paths.clone(),
765 cx,
766 )
767 })?
768 .await;
769
770 window
771 .update(cx, |workspace, cx| {
772 if let Some(ui) = workspace.active_modal::<SshConnectionModal>(cx) {
773 ui.update(cx, |modal, cx| modal.finished(cx))
774 }
775 })
776 .ok();
777
778 if let Err(e) = did_open_ssh_project {
779 log::error!("Failed to open project: {:?}", e);
780 let response = window
781 .update(cx, |_, cx| {
782 cx.prompt(
783 PromptLevel::Critical,
784 "Failed to connect over SSH",
785 Some(&e.to_string()),
786 &["Retry", "Ok"],
787 )
788 })?
789 .await;
790
791 if response == Ok(0) {
792 continue;
793 }
794 }
795
796 break;
797 }
798
799 // Already showed the error to the user
800 Ok(())
801}