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