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