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