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