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