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