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