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