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