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()
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 .ml_1()
199 .text_ellipsis()
200 .overflow_x_hidden()
201 .when_some(self.error_message.as_ref(), |el, error| {
202 el.child(Label::new(format!("{}", error)).size(LabelSize::Small))
203 })
204 .when(
205 self.error_message.is_none() && self.status_message.is_some(),
206 |el| {
207 el.child(
208 Label::new(format!(
209 "{}…",
210 self.status_message.clone().unwrap()
211 ))
212 .size(LabelSize::Small),
213 )
214 },
215 ),
216 ),
217 )
218 .child(div().when_some(self.prompt.as_ref(), |el, prompt| {
219 el.child(
220 h_flex()
221 .p_4()
222 .border_t_1()
223 .border_color(theme.colors().border_variant)
224 .font_buffer(cx)
225 .child(Label::new(prompt.0.clone()))
226 .child(self.editor.clone()),
227 )
228 }))
229 }
230}
231
232impl SshConnectionModal {
233 pub fn new(
234 connection_options: &SshConnectionOptions,
235 is_separate_window: bool,
236 cx: &mut ViewContext<Self>,
237 ) -> Self {
238 Self {
239 prompt: cx.new_view(|cx| SshPrompt::new(connection_options, cx)),
240 is_separate_window,
241 }
242 }
243
244 fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
245 self.prompt.update(cx, |prompt, cx| prompt.confirm(cx))
246 }
247
248 fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
249 cx.emit(DismissEvent);
250 if self.is_separate_window {
251 cx.remove_window();
252 }
253 }
254}
255
256pub(crate) struct SshConnectionHeader {
257 pub(crate) connection_string: SharedString,
258 pub(crate) nickname: Option<SharedString>,
259}
260
261impl RenderOnce for SshConnectionHeader {
262 fn render(self, cx: &mut WindowContext) -> impl IntoElement {
263 let theme = cx.theme();
264
265 let mut header_color = theme.colors().text;
266 header_color.fade_out(0.96);
267
268 let (main_label, meta_label) = if let Some(nickname) = self.nickname {
269 (nickname, Some(format!("({})", self.connection_string)))
270 } else {
271 (self.connection_string, None)
272 };
273
274 h_flex()
275 .p_1()
276 .rounded_t_md()
277 .w_full()
278 .gap_2()
279 .justify_center()
280 .border_b_1()
281 .border_color(theme.colors().border_variant)
282 .bg(header_color)
283 .child(Icon::new(IconName::Server).size(IconSize::XSmall))
284 .child(
285 h_flex()
286 .gap_1()
287 .child(
288 Label::new(main_label)
289 .size(ui::LabelSize::Small)
290 .single_line(),
291 )
292 .children(meta_label.map(|label| {
293 Label::new(label)
294 .size(ui::LabelSize::Small)
295 .single_line()
296 .color(Color::Muted)
297 })),
298 )
299 }
300}
301
302impl Render for SshConnectionModal {
303 fn render(&mut self, cx: &mut ui::ViewContext<Self>) -> impl ui::IntoElement {
304 let connection_string = self.prompt.read(cx).connection_string.clone();
305 let theme = cx.theme();
306
307 let body_color = theme.colors().editor_background;
308
309 v_flex()
310 .elevation_3(cx)
311 .track_focus(&self.focus_handle(cx))
312 .on_action(cx.listener(Self::dismiss))
313 .on_action(cx.listener(Self::confirm))
314 .w(px(500.))
315 .border_1()
316 .border_color(theme.colors().border)
317 .child(
318 SshConnectionHeader {
319 connection_string,
320 nickname: None,
321 }
322 .render(cx),
323 )
324 .child(
325 h_flex()
326 .rounded_b_md()
327 .bg(body_color)
328 .w_full()
329 .child(self.prompt.clone()),
330 )
331 }
332}
333
334impl FocusableView for SshConnectionModal {
335 fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
336 self.prompt.read(cx).editor.focus_handle(cx)
337 }
338}
339
340impl EventEmitter<DismissEvent> for SshConnectionModal {}
341
342impl ModalView for SshConnectionModal {}
343
344#[derive(Clone)]
345pub struct SshClientDelegate {
346 window: AnyWindowHandle,
347 ui: View<SshPrompt>,
348 known_password: Option<String>,
349}
350
351impl remote::SshClientDelegate for SshClientDelegate {
352 fn ask_password(
353 &self,
354 prompt: String,
355 cx: &mut AsyncAppContext,
356 ) -> oneshot::Receiver<Result<String>> {
357 let (tx, rx) = oneshot::channel();
358 let mut known_password = self.known_password.clone();
359 if let Some(password) = known_password.take() {
360 tx.send(Ok(password)).ok();
361 } else {
362 self.window
363 .update(cx, |_, cx| {
364 self.ui.update(cx, |modal, cx| {
365 modal.set_prompt(prompt, tx, cx);
366 })
367 })
368 .ok();
369 }
370 rx
371 }
372
373 fn set_status(&self, status: Option<&str>, cx: &mut AsyncAppContext) {
374 self.update_status(status, cx)
375 }
376
377 fn set_error(&self, error: String, cx: &mut AsyncAppContext) {
378 self.update_error(error, 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 fn update_error(&self, error: String, cx: &mut AsyncAppContext) {
423 self.window
424 .update(cx, |_, cx| {
425 self.ui.update(cx, |modal, cx| {
426 modal.set_error(error, cx);
427 })
428 })
429 .ok();
430 }
431
432 async fn get_server_binary_impl(
433 &self,
434 platform: SshPlatform,
435 cx: &mut AsyncAppContext,
436 ) -> Result<(PathBuf, SemanticVersion)> {
437 let (version, release_channel) = cx.update(|cx| {
438 let global = AppVersion::global(cx);
439 (global, ReleaseChannel::global(cx))
440 })?;
441
442 // In dev mode, build the remote server binary from source
443 #[cfg(debug_assertions)]
444 if release_channel == ReleaseChannel::Dev {
445 let result = self.build_local(cx, platform, version).await?;
446 // Fall through to a remote binary if we're not able to compile a local binary
447 if let Some(result) = result {
448 return Ok(result);
449 }
450 }
451
452 self.update_status(Some("checking for latest version of remote server"), cx);
453 let binary_path = AutoUpdater::get_latest_remote_server_release(
454 platform.os,
455 platform.arch,
456 release_channel,
457 cx,
458 )
459 .await
460 .map_err(|e| {
461 anyhow::anyhow!(
462 "failed to download remote server binary (os: {}, arch: {}): {}",
463 platform.os,
464 platform.arch,
465 e
466 )
467 })?;
468
469 Ok((binary_path, version))
470 }
471
472 #[cfg(debug_assertions)]
473 async fn build_local(
474 &self,
475 cx: &mut AsyncAppContext,
476 platform: SshPlatform,
477 version: SemanticVersion,
478 ) -> Result<Option<(PathBuf, SemanticVersion)>> {
479 use smol::process::{Command, Stdio};
480
481 async fn run_cmd(command: &mut Command) -> Result<()> {
482 let output = command.stderr(Stdio::inherit()).output().await?;
483 if !output.status.success() {
484 Err(anyhow::anyhow!("failed to run command: {:?}", command))?;
485 }
486 Ok(())
487 }
488
489 if platform.arch == std::env::consts::ARCH && platform.os == std::env::consts::OS {
490 self.update_status(Some("Building remote server binary from source"), cx);
491 log::info!("building remote server binary from source");
492 run_cmd(Command::new("cargo").args([
493 "build",
494 "--package",
495 "remote_server",
496 "--features",
497 "debug-embed",
498 "--target-dir",
499 "target/remote_server",
500 ]))
501 .await?;
502
503 self.update_status(Some("Compressing binary"), cx);
504
505 run_cmd(Command::new("gzip").args([
506 "-9",
507 "-f",
508 "target/remote_server/debug/remote_server",
509 ]))
510 .await?;
511
512 let path = std::env::current_dir()?.join("target/remote_server/debug/remote_server.gz");
513 return Ok(Some((path, version)));
514 } else if let Some(triple) = platform.triple() {
515 smol::fs::create_dir_all("target/remote_server").await?;
516
517 self.update_status(Some("Installing cross.rs for cross-compilation"), cx);
518 log::info!("installing cross");
519 run_cmd(Command::new("cargo").args([
520 "install",
521 "cross",
522 "--git",
523 "https://github.com/cross-rs/cross",
524 ]))
525 .await?;
526
527 self.update_status(
528 Some(&format!(
529 "Building remote server binary from source for {}",
530 &triple
531 )),
532 cx,
533 );
534 log::info!("building remote server binary from source for {}", &triple);
535 run_cmd(
536 Command::new("cross")
537 .args([
538 "build",
539 "--package",
540 "remote_server",
541 "--features",
542 "debug-embed",
543 "--target-dir",
544 "target/remote_server",
545 "--target",
546 &triple,
547 ])
548 .env(
549 "CROSS_CONTAINER_OPTS",
550 "--mount type=bind,src=./target,dst=/app/target",
551 ),
552 )
553 .await?;
554
555 self.update_status(Some("Compressing binary"), cx);
556
557 run_cmd(Command::new("gzip").args([
558 "-9",
559 "-f",
560 &format!("target/remote_server/{}/debug/remote_server", triple),
561 ]))
562 .await?;
563
564 let path = std::env::current_dir()?.join(format!(
565 "target/remote_server/{}/debug/remote_server.gz",
566 triple
567 ));
568
569 return Ok(Some((path, version)));
570 } else {
571 return Ok(None);
572 }
573 }
574}
575
576pub fn connect_over_ssh(
577 unique_identifier: String,
578 connection_options: SshConnectionOptions,
579 ui: View<SshPrompt>,
580 cx: &mut WindowContext,
581) -> Task<Result<Model<SshRemoteClient>>> {
582 let window = cx.window_handle();
583 let known_password = connection_options.password.clone();
584
585 remote::SshRemoteClient::new(
586 unique_identifier,
587 connection_options,
588 Arc::new(SshClientDelegate {
589 window,
590 ui,
591 known_password,
592 }),
593 cx,
594 )
595}
596
597pub async fn open_ssh_project(
598 connection_options: SshConnectionOptions,
599 paths: Vec<PathBuf>,
600 app_state: Arc<AppState>,
601 open_options: workspace::OpenOptions,
602 cx: &mut AsyncAppContext,
603) -> Result<()> {
604 let window = if let Some(window) = open_options.replace_window {
605 window
606 } else {
607 let options = cx.update(|cx| (app_state.build_window_options)(None, cx))?;
608 cx.open_window(options, |cx| {
609 let project = project::Project::local(
610 app_state.client.clone(),
611 app_state.node_runtime.clone(),
612 app_state.user_store.clone(),
613 app_state.languages.clone(),
614 app_state.fs.clone(),
615 None,
616 cx,
617 );
618 cx.new_view(|cx| Workspace::new(None, project, app_state.clone(), cx))
619 })?
620 };
621
622 let delegate = window.update(cx, |workspace, cx| {
623 cx.activate_window();
624 workspace.toggle_modal(cx, |cx| {
625 SshConnectionModal::new(&connection_options, true, cx)
626 });
627 let ui = workspace
628 .active_modal::<SshConnectionModal>(cx)
629 .unwrap()
630 .read(cx)
631 .prompt
632 .clone();
633
634 Arc::new(SshClientDelegate {
635 window: cx.window_handle(),
636 ui,
637 known_password: connection_options.password.clone(),
638 })
639 })?;
640
641 let did_open_ssh_project = cx
642 .update(|cx| {
643 workspace::open_ssh_project(
644 window,
645 connection_options,
646 delegate.clone(),
647 app_state,
648 paths,
649 cx,
650 )
651 })?
652 .await;
653
654 let did_open_ssh_project = match did_open_ssh_project {
655 Ok(ok) => Ok(ok),
656 Err(e) => {
657 delegate.update_error(e.to_string(), cx);
658 Err(e)
659 }
660 };
661
662 did_open_ssh_project
663}