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