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