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