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