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, SemanticVersion, SharedString, Task,
15 TextStyleRefinement, WeakEntity,
16};
17
18use language::{CursorShape, Point};
19use markdown::{Markdown, MarkdownElement, MarkdownStyle};
20use release_channel::ReleaseChannel;
21use remote::{
22 ConnectionIdentifier, RemoteClient, RemoteConnection, RemoteConnectionOptions, RemotePlatform,
23 SshConnectionOptions,
24};
25pub use settings::SshConnection;
26use settings::{ExtendingVec, Settings, WslConnection};
27use theme::ThemeSettings;
28use ui::{
29 ActiveTheme, Color, CommonAnimationExt, Context, Icon, IconName, IconSize, InteractiveElement,
30 IntoElement, Label, LabelCommon, Styled, Window, prelude::*,
31};
32use util::paths::PathWithPosition;
33use workspace::{AppState, ModalView, Workspace};
34
35pub struct SshSettings {
36 pub ssh_connections: ExtendingVec<SshConnection>,
37 pub wsl_connections: ExtendingVec<WslConnection>,
38 /// Whether to read ~/.ssh/config for ssh connection sources.
39 pub read_ssh_config: bool,
40}
41
42impl SshSettings {
43 pub fn ssh_connections(&self) -> impl Iterator<Item = SshConnection> + use<> {
44 self.ssh_connections.clone().0.into_iter()
45 }
46
47 pub fn wsl_connections(&self) -> impl Iterator<Item = WslConnection> + use<> {
48 self.wsl_connections.clone().0.into_iter()
49 }
50
51 pub fn fill_connection_options_from_settings(&self, options: &mut SshConnectionOptions) {
52 for conn in self.ssh_connections() {
53 if conn.host == options.host
54 && conn.username == options.username
55 && conn.port == options.port
56 {
57 options.nickname = conn.nickname;
58 options.upload_binary_over_ssh = conn.upload_binary_over_ssh.unwrap_or_default();
59 options.args = Some(conn.args);
60 options.port_forwards = conn.port_forwards;
61 break;
62 }
63 }
64 }
65
66 pub fn connection_options_for(
67 &self,
68 host: String,
69 port: Option<u16>,
70 username: Option<String>,
71 ) -> SshConnectionOptions {
72 let mut options = SshConnectionOptions {
73 host,
74 port,
75 username,
76 ..Default::default()
77 };
78 self.fill_connection_options_from_settings(&mut options);
79 options
80 }
81}
82
83#[derive(Clone, PartialEq)]
84pub enum Connection {
85 Ssh(SshConnection),
86 Wsl(WslConnection),
87}
88
89impl From<Connection> for RemoteConnectionOptions {
90 fn from(val: Connection) -> Self {
91 match val {
92 Connection::Ssh(conn) => RemoteConnectionOptions::Ssh(conn.into()),
93 Connection::Wsl(conn) => RemoteConnectionOptions::Wsl(conn.into()),
94 }
95 }
96}
97
98impl From<SshConnection> for Connection {
99 fn from(val: SshConnection) -> Self {
100 Connection::Ssh(val)
101 }
102}
103
104impl From<WslConnection> for Connection {
105 fn from(val: WslConnection) -> Self {
106 Connection::Wsl(val)
107 }
108}
109
110impl Settings for SshSettings {
111 fn from_settings(content: &settings::SettingsContent) -> Self {
112 let remote = &content.remote;
113 Self {
114 ssh_connections: remote.ssh_connections.clone().unwrap_or_default().into(),
115 wsl_connections: remote.wsl_connections.clone().unwrap_or_default().into(),
116 read_ssh_config: remote.read_ssh_config.unwrap(),
117 }
118 }
119}
120
121pub struct RemoteConnectionPrompt {
122 connection_string: SharedString,
123 nickname: Option<SharedString>,
124 is_wsl: bool,
125 status_message: Option<SharedString>,
126 prompt: Option<(Entity<Markdown>, oneshot::Sender<EncryptedPassword>)>,
127 cancellation: Option<oneshot::Sender<()>>,
128 editor: Entity<Editor>,
129}
130
131impl Drop for RemoteConnectionPrompt {
132 fn drop(&mut self) {
133 if let Some(cancel) = self.cancellation.take() {
134 cancel.send(()).ok();
135 }
136 }
137}
138
139pub struct RemoteConnectionModal {
140 pub(crate) prompt: Entity<RemoteConnectionPrompt>,
141 paths: Vec<PathBuf>,
142 finished: bool,
143}
144
145impl RemoteConnectionPrompt {
146 pub(crate) fn new(
147 connection_string: String,
148 nickname: Option<String>,
149 is_wsl: bool,
150 window: &mut Window,
151 cx: &mut Context<Self>,
152 ) -> Self {
153 Self {
154 connection_string: connection_string.into(),
155 nickname: nickname.map(|nickname| nickname.into()),
156 is_wsl,
157 editor: cx.new(|cx| Editor::single_line(window, cx)),
158 status_message: None,
159 cancellation: None,
160 prompt: None,
161 }
162 }
163
164 pub fn set_cancellation_tx(&mut self, tx: oneshot::Sender<()>) {
165 self.cancellation = Some(tx);
166 }
167
168 fn set_prompt(
169 &mut self,
170 prompt: String,
171 tx: oneshot::Sender<EncryptedPassword>,
172 window: &mut Window,
173 cx: &mut Context<Self>,
174 ) {
175 let theme = ThemeSettings::get_global(cx);
176
177 let refinement = TextStyleRefinement {
178 font_family: Some(theme.buffer_font.family.clone()),
179 font_features: Some(FontFeatures::disable_ligatures()),
180 font_size: Some(theme.buffer_font_size(cx).into()),
181 color: Some(cx.theme().colors().editor_foreground),
182 background_color: Some(gpui::transparent_black()),
183 ..Default::default()
184 };
185
186 self.editor.update(cx, |editor, cx| {
187 if prompt.contains("yes/no") {
188 editor.set_masked(false, cx);
189 } else {
190 editor.set_masked(true, cx);
191 }
192 editor.set_text_style_refinement(refinement);
193 editor.set_cursor_shape(CursorShape::Block, cx);
194 });
195
196 let markdown = cx.new(|cx| Markdown::new_text(prompt.into(), cx));
197 self.prompt = Some((markdown, tx));
198 self.status_message.take();
199 window.focus(&self.editor.focus_handle(cx));
200 cx.notify();
201 }
202
203 pub fn set_status(&mut self, status: Option<String>, cx: &mut Context<Self>) {
204 self.status_message = status.map(|s| s.into());
205 cx.notify();
206 }
207
208 pub fn confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) {
209 if let Some((_, tx)) = self.prompt.take() {
210 self.status_message = Some("Connecting".into());
211
212 self.editor.update(cx, |editor, cx| {
213 let pw = editor.text(cx);
214 if let Ok(secure) = EncryptedPassword::try_from(pw.as_ref()) {
215 tx.send(secure).ok();
216 }
217 editor.clear(window, cx);
218 });
219 }
220 }
221}
222
223impl Render for RemoteConnectionPrompt {
224 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
225 let theme = ThemeSettings::get_global(cx);
226
227 let mut text_style = window.text_style();
228 let refinement = TextStyleRefinement {
229 font_family: Some(theme.buffer_font.family.clone()),
230 font_features: Some(FontFeatures::disable_ligatures()),
231 font_size: Some(theme.buffer_font_size(cx).into()),
232 color: Some(cx.theme().colors().editor_foreground),
233 background_color: Some(gpui::transparent_black()),
234 ..Default::default()
235 };
236
237 text_style.refine(&refinement);
238 let markdown_style = MarkdownStyle {
239 base_text_style: text_style,
240 selection_background_color: cx.theme().colors().element_selection_background,
241 ..Default::default()
242 };
243
244 v_flex()
245 .key_context("PasswordPrompt")
246 .py_2()
247 .px_3()
248 .size_full()
249 .text_buffer(cx)
250 .when_some(self.status_message.clone(), |el, status_message| {
251 el.child(
252 h_flex()
253 .gap_1()
254 .child(
255 Icon::new(IconName::ArrowCircle)
256 .size(IconSize::Medium)
257 .with_rotate_animation(2),
258 )
259 .child(
260 div()
261 .text_ellipsis()
262 .overflow_x_hidden()
263 .child(format!("{}…", status_message)),
264 ),
265 )
266 })
267 .when_some(self.prompt.as_ref(), |el, prompt| {
268 el.child(
269 div()
270 .size_full()
271 .overflow_hidden()
272 .child(MarkdownElement::new(prompt.0.clone(), markdown_style))
273 .child(self.editor.clone()),
274 )
275 .when(window.capslock().on, |el| {
276 el.child(Label::new("⚠️ ⇪ is on"))
277 })
278 })
279 }
280}
281
282impl RemoteConnectionModal {
283 pub(crate) fn new(
284 connection_options: &RemoteConnectionOptions,
285 paths: Vec<PathBuf>,
286 window: &mut Window,
287 cx: &mut Context<Self>,
288 ) -> Self {
289 let (connection_string, nickname, is_wsl) = match connection_options {
290 RemoteConnectionOptions::Ssh(options) => {
291 (options.connection_string(), options.nickname.clone(), false)
292 }
293 RemoteConnectionOptions::Wsl(options) => (options.distro_name.clone(), None, true),
294 };
295 Self {
296 prompt: cx.new(|cx| {
297 RemoteConnectionPrompt::new(connection_string, nickname, is_wsl, window, cx)
298 }),
299 finished: false,
300 paths,
301 }
302 }
303
304 fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
305 self.prompt
306 .update(cx, |prompt, cx| prompt.confirm(window, cx))
307 }
308
309 pub fn finished(&mut self, cx: &mut Context<Self>) {
310 self.finished = true;
311 cx.emit(DismissEvent);
312 }
313
314 fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
315 if let Some(tx) = self
316 .prompt
317 .update(cx, |prompt, _cx| prompt.cancellation.take())
318 {
319 tx.send(()).ok();
320 }
321 self.finished(cx);
322 }
323}
324
325pub(crate) struct SshConnectionHeader {
326 pub(crate) connection_string: SharedString,
327 pub(crate) paths: Vec<PathBuf>,
328 pub(crate) nickname: Option<SharedString>,
329 pub(crate) is_wsl: bool,
330}
331
332impl RenderOnce for SshConnectionHeader {
333 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
334 let theme = cx.theme();
335
336 let mut header_color = theme.colors().text;
337 header_color.fade_out(0.96);
338
339 let (main_label, meta_label) = if let Some(nickname) = self.nickname {
340 (nickname, Some(format!("({})", self.connection_string)))
341 } else {
342 (self.connection_string, None)
343 };
344
345 let icon = match self.is_wsl {
346 true => IconName::Linux,
347 false => IconName::Server,
348 };
349
350 h_flex()
351 .px(DynamicSpacing::Base12.rems(cx))
352 .pt(DynamicSpacing::Base08.rems(cx))
353 .pb(DynamicSpacing::Base04.rems(cx))
354 .rounded_t_sm()
355 .w_full()
356 .gap_1p5()
357 .child(Icon::new(icon).size(IconSize::Small))
358 .child(
359 h_flex()
360 .gap_1()
361 .overflow_x_hidden()
362 .child(
363 div()
364 .max_w_96()
365 .overflow_x_hidden()
366 .text_ellipsis()
367 .child(Headline::new(main_label).size(HeadlineSize::XSmall)),
368 )
369 .children(
370 meta_label.map(|label| {
371 Label::new(label).color(Color::Muted).size(LabelSize::Small)
372 }),
373 )
374 .child(div().overflow_x_hidden().text_ellipsis().children(
375 self.paths.into_iter().map(|path| {
376 Label::new(path.to_string_lossy().into_owned())
377 .size(LabelSize::Small)
378 .color(Color::Muted)
379 }),
380 )),
381 )
382 }
383}
384
385impl Render for RemoteConnectionModal {
386 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
387 let nickname = self.prompt.read(cx).nickname.clone();
388 let connection_string = self.prompt.read(cx).connection_string.clone();
389 let is_wsl = self.prompt.read(cx).is_wsl;
390
391 let theme = cx.theme().clone();
392 let body_color = theme.colors().editor_background;
393
394 v_flex()
395 .elevation_3(cx)
396 .w(rems(34.))
397 .border_1()
398 .border_color(theme.colors().border)
399 .key_context("SshConnectionModal")
400 .track_focus(&self.focus_handle(cx))
401 .on_action(cx.listener(Self::dismiss))
402 .on_action(cx.listener(Self::confirm))
403 .child(
404 SshConnectionHeader {
405 paths: self.paths.clone(),
406 connection_string,
407 nickname,
408 is_wsl,
409 }
410 .render(window, cx),
411 )
412 .child(
413 div()
414 .w_full()
415 .rounded_b_lg()
416 .bg(body_color)
417 .border_t_1()
418 .border_color(theme.colors().border_variant)
419 .child(self.prompt.clone()),
420 )
421 }
422}
423
424impl Focusable for RemoteConnectionModal {
425 fn focus_handle(&self, cx: &gpui::App) -> gpui::FocusHandle {
426 self.prompt.read(cx).editor.focus_handle(cx)
427 }
428}
429
430impl EventEmitter<DismissEvent> for RemoteConnectionModal {}
431
432impl ModalView for RemoteConnectionModal {
433 fn on_before_dismiss(
434 &mut self,
435 _window: &mut Window,
436 _: &mut Context<Self>,
437 ) -> workspace::DismissDecision {
438 workspace::DismissDecision::Dismiss(self.finished)
439 }
440
441 fn fade_out_background(&self) -> bool {
442 true
443 }
444}
445
446#[derive(Clone)]
447pub struct RemoteClientDelegate {
448 window: AnyWindowHandle,
449 ui: WeakEntity<RemoteConnectionPrompt>,
450 known_password: Option<EncryptedPassword>,
451}
452
453impl remote::RemoteClientDelegate for RemoteClientDelegate {
454 fn ask_password(
455 &self,
456 prompt: String,
457 tx: oneshot::Sender<EncryptedPassword>,
458 cx: &mut AsyncApp,
459 ) {
460 let mut known_password = self.known_password.clone();
461 if let Some(password) = known_password.take() {
462 tx.send(password).ok();
463 } else {
464 self.window
465 .update(cx, |_, window, cx| {
466 self.ui.update(cx, |modal, cx| {
467 modal.set_prompt(prompt, tx, window, cx);
468 })
469 })
470 .ok();
471 }
472 }
473
474 fn set_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
475 self.update_status(status, cx)
476 }
477
478 fn download_server_binary_locally(
479 &self,
480 platform: RemotePlatform,
481 release_channel: ReleaseChannel,
482 version: Option<SemanticVersion>,
483 cx: &mut AsyncApp,
484 ) -> Task<anyhow::Result<PathBuf>> {
485 cx.spawn(async move |cx| {
486 AutoUpdater::download_remote_server_release(
487 platform.os,
488 platform.arch,
489 release_channel,
490 version,
491 cx,
492 )
493 .await
494 .with_context(|| {
495 format!(
496 "Downloading remote server binary (version: {}, os: {}, arch: {})",
497 version
498 .map(|v| format!("{}", v))
499 .unwrap_or("unknown".to_string()),
500 platform.os,
501 platform.arch,
502 )
503 })
504 })
505 }
506
507 fn get_download_params(
508 &self,
509 platform: RemotePlatform,
510 release_channel: ReleaseChannel,
511 version: Option<SemanticVersion>,
512 cx: &mut AsyncApp,
513 ) -> Task<Result<Option<(String, String)>>> {
514 cx.spawn(async move |cx| {
515 AutoUpdater::get_remote_server_release_url(
516 platform.os,
517 platform.arch,
518 release_channel,
519 version,
520 cx,
521 )
522 .await
523 })
524 }
525}
526
527impl RemoteClientDelegate {
528 fn update_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
529 self.window
530 .update(cx, |_, _, cx| {
531 self.ui.update(cx, |modal, cx| {
532 modal.set_status(status.map(|s| s.to_string()), cx);
533 })
534 })
535 .ok();
536 }
537}
538
539pub fn connect(
540 unique_identifier: ConnectionIdentifier,
541 connection_options: RemoteConnectionOptions,
542 ui: Entity<RemoteConnectionPrompt>,
543 window: &mut Window,
544 cx: &mut App,
545) -> Task<Result<Option<Entity<RemoteClient>>>> {
546 let window = window.window_handle();
547 let known_password = match &connection_options {
548 RemoteConnectionOptions::Ssh(ssh_connection_options) => ssh_connection_options
549 .password
550 .as_deref()
551 .and_then(|pw| pw.try_into().ok()),
552 _ => None,
553 };
554 let (tx, rx) = oneshot::channel();
555 ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx));
556
557 let delegate = Arc::new(RemoteClientDelegate {
558 window,
559 ui: ui.downgrade(),
560 known_password,
561 });
562
563 cx.spawn(async move |cx| {
564 let connection = remote::connect(connection_options, delegate.clone(), cx).await?;
565 cx.update(|cx| remote::RemoteClient::new(unique_identifier, connection, rx, delegate, cx))?
566 .await
567 })
568}
569
570pub async fn open_remote_project(
571 connection_options: RemoteConnectionOptions,
572 paths: Vec<PathBuf>,
573 app_state: Arc<AppState>,
574 open_options: workspace::OpenOptions,
575 cx: &mut AsyncApp,
576) -> Result<()> {
577 let created_new_window = open_options.replace_window.is_none();
578 let window = if let Some(window) = open_options.replace_window {
579 window
580 } else {
581 let workspace_position = cx
582 .update(|cx| {
583 // todo: These paths are wrong they may have column and line information
584 workspace::remote_workspace_position_from_db(connection_options.clone(), &paths, cx)
585 })?
586 .await
587 .context("fetching ssh workspace position from db")?;
588
589 let mut options =
590 cx.update(|cx| (app_state.build_window_options)(workspace_position.display, cx))?;
591 options.window_bounds = workspace_position.window_bounds;
592
593 cx.open_window(options, |window, cx| {
594 let project = project::Project::local(
595 app_state.client.clone(),
596 app_state.node_runtime.clone(),
597 app_state.user_store.clone(),
598 app_state.languages.clone(),
599 app_state.fs.clone(),
600 None,
601 cx,
602 );
603 cx.new(|cx| {
604 let mut workspace = Workspace::new(None, project, app_state.clone(), window, cx);
605 workspace.centered_layout = workspace_position.centered_layout;
606 workspace
607 })
608 })?
609 };
610
611 loop {
612 let (cancel_tx, cancel_rx) = oneshot::channel();
613 let delegate = window.update(cx, {
614 let paths = paths.clone();
615 let connection_options = connection_options.clone();
616 move |workspace, window, cx| {
617 window.activate_window();
618 workspace.toggle_modal(window, cx, |window, cx| {
619 RemoteConnectionModal::new(&connection_options, paths, window, cx)
620 });
621
622 let ui = workspace
623 .active_modal::<RemoteConnectionModal>(cx)?
624 .read(cx)
625 .prompt
626 .clone();
627
628 ui.update(cx, |ui, _cx| {
629 ui.set_cancellation_tx(cancel_tx);
630 });
631
632 Some(Arc::new(RemoteClientDelegate {
633 window: window.window_handle(),
634 ui: ui.downgrade(),
635 known_password: if let RemoteConnectionOptions::Ssh(options) =
636 &connection_options
637 {
638 options
639 .password
640 .as_deref()
641 .and_then(|pw| EncryptedPassword::try_from(pw).ok())
642 } else {
643 None
644 },
645 }))
646 }
647 })?;
648
649 let Some(delegate) = delegate else { break };
650
651 let remote_connection =
652 match remote::connect(connection_options.clone(), delegate.clone(), cx).await {
653 Ok(connection) => connection,
654 Err(e) => {
655 window
656 .update(cx, |workspace, _, cx| {
657 if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
658 ui.update(cx, |modal, cx| modal.finished(cx))
659 }
660 })
661 .ok();
662 log::error!("Failed to open project: {e:?}");
663 let response = window
664 .update(cx, |_, window, cx| {
665 window.prompt(
666 PromptLevel::Critical,
667 match connection_options {
668 RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
669 RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
670 },
671 Some(&e.to_string()),
672 &["Retry", "Cancel"],
673 cx,
674 )
675 })?
676 .await;
677
678 if response == Ok(0) {
679 continue;
680 }
681
682 if created_new_window {
683 window
684 .update(cx, |_, window, _| window.remove_window())
685 .ok();
686 }
687 break;
688 }
689 };
690
691 let (paths, paths_with_positions) =
692 determine_paths_with_positions(&remote_connection, paths.clone()).await;
693
694 let opened_items = cx
695 .update(|cx| {
696 workspace::open_remote_project_with_new_connection(
697 window,
698 remote_connection,
699 cancel_rx,
700 delegate.clone(),
701 app_state.clone(),
702 paths.clone(),
703 cx,
704 )
705 })?
706 .await;
707
708 window
709 .update(cx, |workspace, _, cx| {
710 if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
711 ui.update(cx, |modal, cx| modal.finished(cx))
712 }
713 })
714 .ok();
715
716 match opened_items {
717 Err(e) => {
718 log::error!("Failed to open project: {e:?}");
719 let response = window
720 .update(cx, |_, window, cx| {
721 window.prompt(
722 PromptLevel::Critical,
723 match connection_options {
724 RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
725 RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
726 },
727 Some(&e.to_string()),
728 &["Retry", "Cancel"],
729 cx,
730 )
731 })?
732 .await;
733 if response == Ok(0) {
734 continue;
735 }
736
737 if created_new_window {
738 window
739 .update(cx, |_, window, _| window.remove_window())
740 .ok();
741 }
742 }
743
744 Ok(items) => {
745 for (item, path) in items.into_iter().zip(paths_with_positions) {
746 let Some(item) = item else {
747 continue;
748 };
749 let Some(row) = path.row else {
750 continue;
751 };
752 if let Some(active_editor) = item.downcast::<Editor>() {
753 window
754 .update(cx, |_, window, cx| {
755 active_editor.update(cx, |editor, cx| {
756 let row = row.saturating_sub(1);
757 let col = path.column.unwrap_or(0).saturating_sub(1);
758 editor.go_to_singleton_buffer_point(
759 Point::new(row, col),
760 window,
761 cx,
762 );
763 });
764 })
765 .ok();
766 }
767 }
768 }
769 }
770
771 window
772 .update(cx, |workspace, _, cx| {
773 if let Some(client) = workspace.project().read(cx).remote_client() {
774 ExtensionStore::global(cx)
775 .update(cx, |store, cx| store.register_remote_client(client, cx));
776 }
777 })
778 .ok();
779
780 break;
781 }
782
783 // Already showed the error to the user
784 Ok(())
785}
786
787pub(crate) async fn determine_paths_with_positions(
788 remote_connection: &Arc<dyn RemoteConnection>,
789 mut paths: Vec<PathBuf>,
790) -> (Vec<PathBuf>, Vec<PathWithPosition>) {
791 let mut paths_with_positions = Vec::<PathWithPosition>::new();
792 for path in &mut paths {
793 if let Some(path_str) = path.to_str() {
794 let path_with_position = PathWithPosition::parse_str(&path_str);
795 if path_with_position.row.is_some() {
796 if !path_exists(&remote_connection, &path).await {
797 *path = path_with_position.path.clone();
798 paths_with_positions.push(path_with_position);
799 continue;
800 }
801 }
802 }
803 paths_with_positions.push(PathWithPosition::from_path(path.clone()))
804 }
805 (paths, paths_with_positions)
806}
807
808async fn path_exists(connection: &Arc<dyn RemoteConnection>, path: &Path) -> bool {
809 let Ok(command) = connection.build_command(
810 Some("test".to_string()),
811 &["-e".to_owned(), path.to_string_lossy().to_string()],
812 &Default::default(),
813 None,
814 None,
815 ) else {
816 return false;
817 };
818 let Ok(mut child) = util::command::new_smol_command(command.program)
819 .args(command.args)
820 .envs(command.env)
821 .spawn()
822 else {
823 return false;
824 };
825 child.status().await.is_ok_and(|status| status.success())
826}