1use std::{
2 path::{Path, PathBuf},
3 sync::Arc,
4};
5
6use anyhow::{Context as _, Result};
7use askpass::EncryptedPassword;
8use auto_update::AutoUpdater;
9use editor::Editor;
10use extension_host::ExtensionStore;
11use futures::channel::oneshot;
12use gpui::{
13 AnyWindowHandle, App, AsyncApp, DismissEvent, Entity, EventEmitter, Focusable, FontFeatures,
14 ParentElement as _, PromptLevel, Render, SharedString, Task, TextStyleRefinement, WeakEntity,
15};
16
17use language::{CursorShape, Point};
18use markdown::{Markdown, MarkdownElement, MarkdownStyle};
19use project::trusted_worktrees;
20use release_channel::ReleaseChannel;
21use remote::{
22 ConnectionIdentifier, DockerConnectionOptions, RemoteClient, RemoteConnection,
23 RemoteConnectionOptions, RemotePlatform, SshConnectionOptions,
24};
25use semver::Version;
26pub use settings::SshConnection;
27use settings::{DevContainerConnection, ExtendingVec, RegisterSetting, Settings, WslConnection};
28use theme::ThemeSettings;
29use ui::{
30 ActiveTheme, Color, CommonAnimationExt, Context, InteractiveElement, IntoElement, KeyBinding,
31 LabelCommon, ListItem, Styled, Window, prelude::*,
32};
33use util::paths::PathWithPosition;
34use workspace::{AppState, ModalView, Workspace};
35
36#[derive(RegisterSetting)]
37pub struct RemoteSettings {
38 pub ssh_connections: ExtendingVec<SshConnection>,
39 pub wsl_connections: ExtendingVec<WslConnection>,
40 /// Whether to read ~/.ssh/config for ssh connection sources.
41 pub read_ssh_config: bool,
42}
43
44impl RemoteSettings {
45 pub fn ssh_connections(&self) -> impl Iterator<Item = SshConnection> + use<> {
46 self.ssh_connections.clone().0.into_iter()
47 }
48
49 pub fn wsl_connections(&self) -> impl Iterator<Item = WslConnection> + use<> {
50 self.wsl_connections.clone().0.into_iter()
51 }
52
53 pub fn fill_connection_options_from_settings(&self, options: &mut SshConnectionOptions) {
54 for conn in self.ssh_connections() {
55 if conn.host == options.host.to_string()
56 && conn.username == options.username
57 && conn.port == options.port
58 {
59 options.nickname = conn.nickname;
60 options.upload_binary_over_ssh = conn.upload_binary_over_ssh.unwrap_or_default();
61 options.args = Some(conn.args);
62 options.port_forwards = conn.port_forwards;
63 break;
64 }
65 }
66 }
67
68 pub fn connection_options_for(
69 &self,
70 host: String,
71 port: Option<u16>,
72 username: Option<String>,
73 ) -> SshConnectionOptions {
74 let mut options = SshConnectionOptions {
75 host: host.into(),
76 port,
77 username,
78 ..Default::default()
79 };
80 self.fill_connection_options_from_settings(&mut options);
81 options
82 }
83}
84
85#[derive(Clone, PartialEq)]
86pub enum Connection {
87 Ssh(SshConnection),
88 Wsl(WslConnection),
89 DevContainer(DevContainerConnection),
90}
91
92impl From<Connection> for RemoteConnectionOptions {
93 fn from(val: Connection) -> Self {
94 match val {
95 Connection::Ssh(conn) => RemoteConnectionOptions::Ssh(conn.into()),
96 Connection::Wsl(conn) => RemoteConnectionOptions::Wsl(conn.into()),
97 Connection::DevContainer(conn) => {
98 RemoteConnectionOptions::Docker(DockerConnectionOptions {
99 name: conn.name.to_string(),
100 container_id: conn.container_id.to_string(),
101 upload_binary_over_docker_exec: false,
102 })
103 }
104 }
105 }
106}
107
108impl From<SshConnection> for Connection {
109 fn from(val: SshConnection) -> Self {
110 Connection::Ssh(val)
111 }
112}
113
114impl From<WslConnection> for Connection {
115 fn from(val: WslConnection) -> Self {
116 Connection::Wsl(val)
117 }
118}
119
120impl Settings for RemoteSettings {
121 fn from_settings(content: &settings::SettingsContent) -> Self {
122 let remote = &content.remote;
123 Self {
124 ssh_connections: remote.ssh_connections.clone().unwrap_or_default().into(),
125 wsl_connections: remote.wsl_connections.clone().unwrap_or_default().into(),
126 read_ssh_config: remote.read_ssh_config.unwrap(),
127 }
128 }
129}
130
131pub struct RemoteConnectionPrompt {
132 connection_string: SharedString,
133 nickname: Option<SharedString>,
134 is_wsl: bool,
135 is_devcontainer: bool,
136 status_message: Option<SharedString>,
137 prompt: Option<(Entity<Markdown>, oneshot::Sender<EncryptedPassword>)>,
138 cancellation: Option<oneshot::Sender<()>>,
139 editor: Entity<Editor>,
140}
141
142impl Drop for RemoteConnectionPrompt {
143 fn drop(&mut self) {
144 if let Some(cancel) = self.cancellation.take() {
145 cancel.send(()).ok();
146 }
147 }
148}
149
150pub struct RemoteConnectionModal {
151 pub prompt: Entity<RemoteConnectionPrompt>,
152 paths: Vec<PathBuf>,
153 finished: bool,
154}
155
156impl RemoteConnectionPrompt {
157 pub(crate) fn new(
158 connection_string: String,
159 nickname: Option<String>,
160 is_wsl: bool,
161 is_devcontainer: bool,
162 window: &mut Window,
163 cx: &mut Context<Self>,
164 ) -> Self {
165 Self {
166 connection_string: connection_string.into(),
167 nickname: nickname.map(|nickname| nickname.into()),
168 is_wsl,
169 is_devcontainer,
170 editor: cx.new(|cx| Editor::single_line(window, cx)),
171 status_message: None,
172 cancellation: None,
173 prompt: None,
174 }
175 }
176
177 pub fn set_cancellation_tx(&mut self, tx: oneshot::Sender<()>) {
178 self.cancellation = Some(tx);
179 }
180
181 fn set_prompt(
182 &mut self,
183 prompt: String,
184 tx: oneshot::Sender<EncryptedPassword>,
185 window: &mut Window,
186 cx: &mut Context<Self>,
187 ) {
188 let theme = ThemeSettings::get_global(cx);
189
190 let refinement = TextStyleRefinement {
191 font_family: Some(theme.buffer_font.family.clone()),
192 font_features: Some(FontFeatures::disable_ligatures()),
193 font_size: Some(theme.buffer_font_size(cx).into()),
194 color: Some(cx.theme().colors().editor_foreground),
195 background_color: Some(gpui::transparent_black()),
196 ..Default::default()
197 };
198
199 self.editor.update(cx, |editor, cx| {
200 if prompt.contains("yes/no") {
201 editor.set_masked(false, cx);
202 } else {
203 editor.set_masked(true, cx);
204 }
205 editor.set_text_style_refinement(refinement);
206 editor.set_cursor_shape(CursorShape::Block, cx);
207 });
208
209 let markdown = cx.new(|cx| Markdown::new_text(prompt.into(), cx));
210 self.prompt = Some((markdown, tx));
211 self.status_message.take();
212 window.focus(&self.editor.focus_handle(cx), cx);
213 cx.notify();
214 }
215
216 pub fn set_status(&mut self, status: Option<String>, cx: &mut Context<Self>) {
217 self.status_message = status.map(|s| s.into());
218 cx.notify();
219 }
220
221 pub fn confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
222 if let Some((_, tx)) = self.prompt.take() {
223 self.status_message = Some("Connecting".into());
224
225 self.editor.update(cx, |editor, cx| {
226 let pw = editor.text(cx);
227 if let Ok(secure) = EncryptedPassword::try_from(pw.as_ref()) {
228 tx.send(secure).ok();
229 }
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 .p_2()
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_2()
266 .child(
267 Icon::new(IconName::ArrowCircle)
268 .color(Color::Muted)
269 .with_rotate_animation(2),
270 )
271 .child(
272 div()
273 .text_ellipsis()
274 .overflow_x_hidden()
275 .child(format!("{}…", status_message)),
276 ),
277 )
278 })
279 .when_some(self.prompt.as_ref(), |el, prompt| {
280 el.child(
281 div()
282 .size_full()
283 .overflow_hidden()
284 .child(MarkdownElement::new(prompt.0.clone(), markdown_style))
285 .child(self.editor.clone()),
286 )
287 .when(window.capslock().on, |el| {
288 el.child(Label::new("⚠️ ⇪ is on"))
289 })
290 })
291 }
292}
293
294impl RemoteConnectionModal {
295 pub fn new(
296 connection_options: &RemoteConnectionOptions,
297 paths: Vec<PathBuf>,
298 window: &mut Window,
299 cx: &mut Context<Self>,
300 ) -> Self {
301 let (connection_string, nickname, is_wsl, is_devcontainer) = match connection_options {
302 RemoteConnectionOptions::Ssh(options) => (
303 options.connection_string(),
304 options.nickname.clone(),
305 false,
306 false,
307 ),
308 RemoteConnectionOptions::Wsl(options) => {
309 (options.distro_name.clone(), None, true, false)
310 }
311 RemoteConnectionOptions::Docker(options) => (options.name.clone(), None, false, true),
312 #[cfg(any(test, feature = "test-support"))]
313 RemoteConnectionOptions::Mock(options) => {
314 (format!("mock-{}", options.id), None, false, false)
315 }
316 };
317 Self {
318 prompt: cx.new(|cx| {
319 RemoteConnectionPrompt::new(
320 connection_string,
321 nickname,
322 is_wsl,
323 is_devcontainer,
324 window,
325 cx,
326 )
327 }),
328 finished: false,
329 paths,
330 }
331 }
332
333 fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
334 self.prompt
335 .update(cx, |prompt, cx| prompt.confirm(window, cx))
336 }
337
338 pub fn finished(&mut self, cx: &mut Context<Self>) {
339 self.finished = true;
340 cx.emit(DismissEvent);
341 }
342
343 fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
344 if let Some(tx) = self
345 .prompt
346 .update(cx, |prompt, _cx| prompt.cancellation.take())
347 {
348 tx.send(()).ok();
349 }
350 self.finished(cx);
351 }
352}
353
354pub(crate) struct SshConnectionHeader {
355 pub(crate) connection_string: SharedString,
356 pub(crate) paths: Vec<PathBuf>,
357 pub(crate) nickname: Option<SharedString>,
358 pub(crate) is_wsl: bool,
359 pub(crate) is_devcontainer: bool,
360}
361
362impl RenderOnce for SshConnectionHeader {
363 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
364 let theme = cx.theme();
365
366 let mut header_color = theme.colors().text;
367 header_color.fade_out(0.96);
368
369 let (main_label, meta_label) = if let Some(nickname) = self.nickname {
370 (nickname, Some(format!("({})", self.connection_string)))
371 } else {
372 (self.connection_string, None)
373 };
374
375 let icon = if self.is_wsl {
376 IconName::Linux
377 } else if self.is_devcontainer {
378 IconName::Box
379 } else {
380 IconName::Server
381 };
382
383 h_flex()
384 .px(DynamicSpacing::Base12.rems(cx))
385 .pt(DynamicSpacing::Base08.rems(cx))
386 .pb(DynamicSpacing::Base04.rems(cx))
387 .rounded_t_sm()
388 .w_full()
389 .gap_1p5()
390 .child(Icon::new(icon).size(IconSize::Small))
391 .child(
392 h_flex()
393 .gap_1()
394 .overflow_x_hidden()
395 .child(
396 div()
397 .max_w_96()
398 .overflow_x_hidden()
399 .text_ellipsis()
400 .child(Headline::new(main_label).size(HeadlineSize::XSmall)),
401 )
402 .children(
403 meta_label.map(|label| {
404 Label::new(label).color(Color::Muted).size(LabelSize::Small)
405 }),
406 )
407 .child(div().overflow_x_hidden().text_ellipsis().children(
408 self.paths.into_iter().map(|path| {
409 Label::new(path.to_string_lossy().into_owned())
410 .size(LabelSize::Small)
411 .color(Color::Muted)
412 }),
413 )),
414 )
415 }
416}
417
418impl Render for RemoteConnectionModal {
419 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
420 let nickname = self.prompt.read(cx).nickname.clone();
421 let connection_string = self.prompt.read(cx).connection_string.clone();
422 let is_wsl = self.prompt.read(cx).is_wsl;
423 let is_devcontainer = self.prompt.read(cx).is_devcontainer;
424
425 let theme = cx.theme().clone();
426 let body_color = theme.colors().editor_background;
427
428 v_flex()
429 .elevation_3(cx)
430 .w(rems(34.))
431 .border_1()
432 .border_color(theme.colors().border)
433 .key_context("SshConnectionModal")
434 .track_focus(&self.focus_handle(cx))
435 .on_action(cx.listener(Self::dismiss))
436 .on_action(cx.listener(Self::confirm))
437 .child(
438 SshConnectionHeader {
439 paths: self.paths.clone(),
440 connection_string,
441 nickname,
442 is_wsl,
443 is_devcontainer,
444 }
445 .render(window, cx),
446 )
447 .child(
448 div()
449 .w_full()
450 .bg(body_color)
451 .border_y_1()
452 .border_color(theme.colors().border_variant)
453 .child(self.prompt.clone()),
454 )
455 .child(
456 div().w_full().py_1().child(
457 ListItem::new("li-devcontainer-go-back")
458 .inset(true)
459 .spacing(ui::ListItemSpacing::Sparse)
460 .start_slot(Icon::new(IconName::Close).color(Color::Muted))
461 .child(Label::new("Cancel"))
462 .end_slot(
463 KeyBinding::for_action_in(&menu::Cancel, &self.focus_handle(cx), cx)
464 .size(rems_from_px(12.)),
465 )
466 .on_click(cx.listener(|this, _, window, cx| {
467 this.dismiss(&menu::Cancel, window, cx);
468 })),
469 ),
470 )
471 }
472}
473
474impl Focusable for RemoteConnectionModal {
475 fn focus_handle(&self, cx: &gpui::App) -> gpui::FocusHandle {
476 self.prompt.read(cx).editor.focus_handle(cx)
477 }
478}
479
480impl EventEmitter<DismissEvent> for RemoteConnectionModal {}
481
482impl ModalView for RemoteConnectionModal {
483 fn on_before_dismiss(
484 &mut self,
485 _window: &mut Window,
486 _: &mut Context<Self>,
487 ) -> workspace::DismissDecision {
488 workspace::DismissDecision::Dismiss(self.finished)
489 }
490
491 fn fade_out_background(&self) -> bool {
492 true
493 }
494}
495
496#[derive(Clone)]
497pub struct RemoteClientDelegate {
498 window: AnyWindowHandle,
499 ui: WeakEntity<RemoteConnectionPrompt>,
500 known_password: Option<EncryptedPassword>,
501}
502
503impl remote::RemoteClientDelegate for RemoteClientDelegate {
504 fn ask_password(
505 &self,
506 prompt: String,
507 tx: oneshot::Sender<EncryptedPassword>,
508 cx: &mut AsyncApp,
509 ) {
510 let mut known_password = self.known_password.clone();
511 if let Some(password) = known_password.take() {
512 tx.send(password).ok();
513 } else {
514 self.window
515 .update(cx, |_, window, cx| {
516 self.ui.update(cx, |modal, cx| {
517 modal.set_prompt(prompt, tx, window, cx);
518 })
519 })
520 .ok();
521 }
522 }
523
524 fn set_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
525 self.update_status(status, cx)
526 }
527
528 fn download_server_binary_locally(
529 &self,
530 platform: RemotePlatform,
531 release_channel: ReleaseChannel,
532 version: Option<Version>,
533 cx: &mut AsyncApp,
534 ) -> Task<anyhow::Result<PathBuf>> {
535 let this = self.clone();
536 cx.spawn(async move |cx| {
537 AutoUpdater::download_remote_server_release(
538 release_channel,
539 version.clone(),
540 platform.os.as_str(),
541 platform.arch.as_str(),
542 move |status, cx| this.set_status(Some(status), cx),
543 cx,
544 )
545 .await
546 .with_context(|| {
547 format!(
548 "Downloading remote server binary (version: {}, os: {}, arch: {})",
549 version
550 .as_ref()
551 .map(|v| format!("{}", v))
552 .unwrap_or("unknown".to_string()),
553 platform.os,
554 platform.arch,
555 )
556 })
557 })
558 }
559
560 fn get_download_url(
561 &self,
562 platform: RemotePlatform,
563 release_channel: ReleaseChannel,
564 version: Option<Version>,
565 cx: &mut AsyncApp,
566 ) -> Task<Result<Option<String>>> {
567 cx.spawn(async move |cx| {
568 AutoUpdater::get_remote_server_release_url(
569 release_channel,
570 version,
571 platform.os.as_str(),
572 platform.arch.as_str(),
573 cx,
574 )
575 .await
576 })
577 }
578}
579
580impl RemoteClientDelegate {
581 fn update_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
582 self.window
583 .update(cx, |_, _, cx| {
584 self.ui.update(cx, |modal, cx| {
585 modal.set_status(status.map(|s| s.to_string()), cx);
586 })
587 })
588 .ok();
589 }
590}
591
592pub fn connect(
593 unique_identifier: ConnectionIdentifier,
594 connection_options: RemoteConnectionOptions,
595 ui: Entity<RemoteConnectionPrompt>,
596 window: &mut Window,
597 cx: &mut App,
598) -> Task<Result<Option<Entity<RemoteClient>>>> {
599 let window = window.window_handle();
600 let known_password = match &connection_options {
601 RemoteConnectionOptions::Ssh(ssh_connection_options) => ssh_connection_options
602 .password
603 .as_deref()
604 .and_then(|pw| pw.try_into().ok()),
605 _ => None,
606 };
607 let (tx, rx) = oneshot::channel();
608 ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx));
609
610 let delegate = Arc::new(RemoteClientDelegate {
611 window,
612 ui: ui.downgrade(),
613 known_password,
614 });
615
616 cx.spawn(async move |cx| {
617 let connection = remote::connect(connection_options, delegate.clone(), cx).await?;
618 cx.update(|cx| remote::RemoteClient::new(unique_identifier, connection, rx, delegate, cx))
619 .await
620 })
621}
622
623pub async fn open_remote_project(
624 connection_options: RemoteConnectionOptions,
625 paths: Vec<PathBuf>,
626 app_state: Arc<AppState>,
627 open_options: workspace::OpenOptions,
628 cx: &mut AsyncApp,
629) -> Result<()> {
630 let created_new_window = open_options.replace_window.is_none();
631 let window = if let Some(window) = open_options.replace_window {
632 window
633 } else {
634 let workspace_position = cx
635 .update(|cx| {
636 // todo: These paths are wrong they may have column and line information
637 workspace::remote_workspace_position_from_db(connection_options.clone(), &paths, cx)
638 })
639 .await
640 .context("fetching remote workspace position from db")?;
641
642 let mut options =
643 cx.update(|cx| (app_state.build_window_options)(workspace_position.display, cx));
644 options.window_bounds = workspace_position.window_bounds;
645
646 cx.open_window(options, |window, cx| {
647 let project = project::Project::local(
648 app_state.client.clone(),
649 app_state.node_runtime.clone(),
650 app_state.user_store.clone(),
651 app_state.languages.clone(),
652 app_state.fs.clone(),
653 None,
654 false,
655 cx,
656 );
657 cx.new(|cx| {
658 let mut workspace = Workspace::new(None, project, app_state.clone(), window, cx);
659 workspace.centered_layout = workspace_position.centered_layout;
660 workspace
661 })
662 })?
663 };
664
665 loop {
666 let (cancel_tx, cancel_rx) = oneshot::channel();
667 let delegate = window.update(cx, {
668 let paths = paths.clone();
669 let connection_options = connection_options.clone();
670 move |workspace, window, cx| {
671 window.activate_window();
672 workspace.toggle_modal(window, cx, |window, cx| {
673 RemoteConnectionModal::new(&connection_options, paths, window, cx)
674 });
675
676 let ui = workspace
677 .active_modal::<RemoteConnectionModal>(cx)?
678 .read(cx)
679 .prompt
680 .clone();
681
682 ui.update(cx, |ui, _cx| {
683 ui.set_cancellation_tx(cancel_tx);
684 });
685
686 Some(Arc::new(RemoteClientDelegate {
687 window: window.window_handle(),
688 ui: ui.downgrade(),
689 known_password: if let RemoteConnectionOptions::Ssh(options) =
690 &connection_options
691 {
692 options
693 .password
694 .as_deref()
695 .and_then(|pw| EncryptedPassword::try_from(pw).ok())
696 } else {
697 None
698 },
699 }))
700 }
701 })?;
702
703 let Some(delegate) = delegate else { break };
704
705 let remote_connection =
706 match remote::connect(connection_options.clone(), delegate.clone(), cx).await {
707 Ok(connection) => connection,
708 Err(e) => {
709 window
710 .update(cx, |workspace, _, cx| {
711 if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
712 ui.update(cx, |modal, cx| modal.finished(cx))
713 }
714 })
715 .ok();
716 log::error!("Failed to open project: {e:#}");
717 let response = window
718 .update(cx, |_, window, cx| {
719 window.prompt(
720 PromptLevel::Critical,
721 match connection_options {
722 RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
723 RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
724 RemoteConnectionOptions::Docker(_) => {
725 "Failed to connect to Dev Container"
726 }
727 #[cfg(any(test, feature = "test-support"))]
728 RemoteConnectionOptions::Mock(_) => {
729 "Failed to connect to mock server"
730 }
731 },
732 Some(&format!("{e:#}")),
733 &["Retry", "Cancel"],
734 cx,
735 )
736 })?
737 .await;
738
739 if response == Ok(0) {
740 continue;
741 }
742
743 if created_new_window {
744 window
745 .update(cx, |_, window, _| window.remove_window())
746 .ok();
747 }
748 break;
749 }
750 };
751
752 let (paths, paths_with_positions) =
753 determine_paths_with_positions(&remote_connection, paths.clone()).await;
754
755 let opened_items = cx
756 .update(|cx| {
757 workspace::open_remote_project_with_new_connection(
758 window,
759 remote_connection,
760 cancel_rx,
761 delegate.clone(),
762 app_state.clone(),
763 paths.clone(),
764 cx,
765 )
766 })
767 .await;
768
769 window
770 .update(cx, |workspace, _, cx| {
771 if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
772 ui.update(cx, |modal, cx| modal.finished(cx))
773 }
774 })
775 .ok();
776
777 match opened_items {
778 Err(e) => {
779 log::error!("Failed to open project: {e:#}");
780 let response = window
781 .update(cx, |_, window, cx| {
782 window.prompt(
783 PromptLevel::Critical,
784 match connection_options {
785 RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
786 RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
787 RemoteConnectionOptions::Docker(_) => {
788 "Failed to connect to Dev Container"
789 }
790 #[cfg(any(test, feature = "test-support"))]
791 RemoteConnectionOptions::Mock(_) => {
792 "Failed to connect to mock server"
793 }
794 },
795 Some(&format!("{e:#}")),
796 &["Retry", "Cancel"],
797 cx,
798 )
799 })?
800 .await;
801 if response == Ok(0) {
802 continue;
803 }
804
805 window
806 .update(cx, |workspace, window, cx| {
807 if created_new_window {
808 window.remove_window();
809 }
810 trusted_worktrees::track_worktree_trust(
811 workspace.project().read(cx).worktree_store(),
812 None,
813 None,
814 None,
815 cx,
816 );
817 })
818 .ok();
819 }
820
821 Ok(items) => {
822 for (item, path) in items.into_iter().zip(paths_with_positions) {
823 let Some(item) = item else {
824 continue;
825 };
826 let Some(row) = path.row else {
827 continue;
828 };
829 if let Some(active_editor) = item.downcast::<Editor>() {
830 window
831 .update(cx, |_, window, cx| {
832 active_editor.update(cx, |editor, cx| {
833 let row = row.saturating_sub(1);
834 let col = path.column.unwrap_or(0).saturating_sub(1);
835 editor.go_to_singleton_buffer_point(
836 Point::new(row, col),
837 window,
838 cx,
839 );
840 });
841 })
842 .ok();
843 }
844 }
845 }
846 }
847
848 window
849 .update(cx, |workspace, _, cx| {
850 if let Some(client) = workspace.project().read(cx).remote_client() {
851 if let Some(extension_store) = ExtensionStore::try_global(cx) {
852 extension_store
853 .update(cx, |store, cx| store.register_remote_client(client, cx));
854 }
855 }
856 })
857 .ok();
858
859 break;
860 }
861
862 // Already showed the error to the user
863 Ok(())
864}
865
866pub(crate) async fn determine_paths_with_positions(
867 remote_connection: &Arc<dyn RemoteConnection>,
868 mut paths: Vec<PathBuf>,
869) -> (Vec<PathBuf>, Vec<PathWithPosition>) {
870 let mut paths_with_positions = Vec::<PathWithPosition>::new();
871 for path in &mut paths {
872 if let Some(path_str) = path.to_str() {
873 let path_with_position = PathWithPosition::parse_str(&path_str);
874 if path_with_position.row.is_some() {
875 if !path_exists(&remote_connection, &path).await {
876 *path = path_with_position.path.clone();
877 paths_with_positions.push(path_with_position);
878 continue;
879 }
880 }
881 }
882 paths_with_positions.push(PathWithPosition::from_path(path.clone()))
883 }
884 (paths, paths_with_positions)
885}
886
887async fn path_exists(connection: &Arc<dyn RemoteConnection>, path: &Path) -> bool {
888 let Ok(command) = connection.build_command(
889 Some("test".to_string()),
890 &["-e".to_owned(), path.to_string_lossy().to_string()],
891 &Default::default(),
892 None,
893 None,
894 ) else {
895 return false;
896 };
897 let Ok(mut child) = util::command::new_smol_command(command.program)
898 .args(command.args)
899 .envs(command.env)
900 .spawn()
901 else {
902 return false;
903 };
904 child.status().await.is_ok_and(|status| status.success())
905}
906
907#[cfg(test)]
908mod tests {
909 use super::*;
910 use extension::ExtensionHostProxy;
911 use fs::FakeFs;
912 use gpui::TestAppContext;
913 use http_client::BlockedHttpClient;
914 use node_runtime::NodeRuntime;
915 use remote::RemoteClient;
916 use remote_server::{HeadlessAppState, HeadlessProject};
917 use serde_json::json;
918 use util::path;
919
920 #[gpui::test]
921 async fn test_open_remote_project_with_mock_connection(
922 cx: &mut TestAppContext,
923 server_cx: &mut TestAppContext,
924 ) {
925 let app_state = init_test(cx);
926 let executor = cx.executor();
927
928 cx.update(|cx| {
929 release_channel::init(semver::Version::new(0, 0, 0), cx);
930 });
931 server_cx.update(|cx| {
932 release_channel::init(semver::Version::new(0, 0, 0), cx);
933 });
934
935 let (opts, server_session, connect_guard) = RemoteClient::fake_server(cx, server_cx);
936
937 let remote_fs = FakeFs::new(server_cx.executor());
938 remote_fs
939 .insert_tree(
940 path!("/project"),
941 json!({
942 "src": {
943 "main.rs": "fn main() {}",
944 },
945 "README.md": "# Test Project",
946 }),
947 )
948 .await;
949
950 server_cx.update(HeadlessProject::init);
951 let http_client = Arc::new(BlockedHttpClient);
952 let node_runtime = NodeRuntime::unavailable();
953 let languages = Arc::new(language::LanguageRegistry::new(server_cx.executor()));
954 let proxy = Arc::new(ExtensionHostProxy::new());
955
956 let _headless = server_cx.new(|cx| {
957 HeadlessProject::new(
958 HeadlessAppState {
959 session: server_session,
960 fs: remote_fs.clone(),
961 http_client,
962 node_runtime,
963 languages,
964 extension_host_proxy: proxy,
965 },
966 false,
967 cx,
968 )
969 });
970
971 drop(connect_guard);
972
973 let paths = vec![PathBuf::from(path!("/project"))];
974 let open_options = workspace::OpenOptions::default();
975
976 let mut async_cx = cx.to_async();
977 let result = open_remote_project(opts, paths, app_state, open_options, &mut async_cx).await;
978
979 executor.run_until_parked();
980
981 assert!(result.is_ok(), "open_remote_project should succeed");
982
983 let windows = cx.update(|cx| cx.windows().len());
984 assert_eq!(windows, 1, "Should have opened a window");
985
986 let workspace_handle = cx.update(|cx| cx.windows()[0].downcast::<Workspace>().unwrap());
987
988 workspace_handle
989 .update(cx, |workspace, _, cx| {
990 let project = workspace.project().read(cx);
991 assert!(project.is_remote(), "Project should be a remote project");
992 })
993 .unwrap();
994 }
995
996 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
997 cx.update(|cx| {
998 let state = AppState::test(cx);
999 crate::init(cx);
1000 editor::init(cx);
1001 state
1002 })
1003 }
1004}