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