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