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