1use std::{
2 path::{Path, PathBuf},
3 sync::Arc,
4};
5
6use anyhow::{Context as _, Result};
7use askpass::EncryptedPassword;
8use editor::Editor;
9use extension_host::ExtensionStore;
10use futures::{FutureExt as _, channel::oneshot, select};
11use gpui::{AppContext, AsyncApp, PromptLevel, WindowHandle};
12
13use language::Point;
14use project::trusted_worktrees;
15use remote::{
16 DockerConnectionOptions, Interactive, RemoteConnection, RemoteConnectionOptions,
17 SshConnectionOptions,
18};
19pub use settings::SshConnection;
20use settings::{DevContainerConnection, ExtendingVec, RegisterSetting, Settings, WslConnection};
21use util::paths::PathWithPosition;
22use workspace::{
23 AppState, OpenOptions, SerializedWorkspaceLocation, Workspace, find_existing_workspace,
24};
25
26pub use remote_connection::{
27 RemoteClientDelegate, RemoteConnectionModal, RemoteConnectionPrompt, SshConnectionHeader,
28 connect,
29};
30
31#[derive(RegisterSetting)]
32pub struct RemoteSettings {
33 pub ssh_connections: ExtendingVec<SshConnection>,
34 pub wsl_connections: ExtendingVec<WslConnection>,
35 /// Whether to read ~/.ssh/config for ssh connection sources.
36 pub read_ssh_config: bool,
37}
38
39impl RemoteSettings {
40 pub fn ssh_connections(&self) -> impl Iterator<Item = SshConnection> + use<> {
41 self.ssh_connections.clone().0.into_iter()
42 }
43
44 pub fn wsl_connections(&self) -> impl Iterator<Item = WslConnection> + use<> {
45 self.wsl_connections.clone().0.into_iter()
46 }
47
48 pub fn fill_connection_options_from_settings(&self, options: &mut SshConnectionOptions) {
49 for conn in self.ssh_connections() {
50 if conn.host == options.host.to_string()
51 && conn.username == options.username
52 && conn.port == options.port
53 {
54 options.nickname = conn.nickname;
55 options.upload_binary_over_ssh = conn.upload_binary_over_ssh.unwrap_or_default();
56 options.args = Some(conn.args);
57 options.port_forwards = conn.port_forwards;
58 break;
59 }
60 }
61 }
62
63 pub fn connection_options_for(
64 &self,
65 host: String,
66 port: Option<u16>,
67 username: Option<String>,
68 ) -> SshConnectionOptions {
69 let mut options = SshConnectionOptions {
70 host: host.into(),
71 port,
72 username,
73 ..Default::default()
74 };
75 self.fill_connection_options_from_settings(&mut options);
76 options
77 }
78}
79
80#[derive(Clone, PartialEq)]
81pub enum Connection {
82 Ssh(SshConnection),
83 Wsl(WslConnection),
84 DevContainer(DevContainerConnection),
85}
86
87impl From<Connection> for RemoteConnectionOptions {
88 fn from(val: Connection) -> Self {
89 match val {
90 Connection::Ssh(conn) => RemoteConnectionOptions::Ssh(conn.into()),
91 Connection::Wsl(conn) => RemoteConnectionOptions::Wsl(conn.into()),
92 Connection::DevContainer(conn) => {
93 RemoteConnectionOptions::Docker(DockerConnectionOptions {
94 name: conn.name,
95 remote_user: conn.remote_user,
96 container_id: conn.container_id,
97 upload_binary_over_docker_exec: false,
98 use_podman: conn.use_podman,
99 })
100 }
101 }
102 }
103}
104
105impl From<SshConnection> for Connection {
106 fn from(val: SshConnection) -> Self {
107 Connection::Ssh(val)
108 }
109}
110
111impl From<WslConnection> for Connection {
112 fn from(val: WslConnection) -> Self {
113 Connection::Wsl(val)
114 }
115}
116
117impl Settings for RemoteSettings {
118 fn from_settings(content: &settings::SettingsContent) -> Self {
119 let remote = &content.remote;
120 Self {
121 ssh_connections: remote.ssh_connections.clone().unwrap_or_default().into(),
122 wsl_connections: remote.wsl_connections.clone().unwrap_or_default().into(),
123 read_ssh_config: remote.read_ssh_config.unwrap(),
124 }
125 }
126}
127
128pub async fn open_remote_project(
129 connection_options: RemoteConnectionOptions,
130 paths: Vec<PathBuf>,
131 app_state: Arc<AppState>,
132 open_options: workspace::OpenOptions,
133 cx: &mut AsyncApp,
134) -> Result<()> {
135 let created_new_window = open_options.replace_window.is_none();
136
137 let (existing, open_visible) = find_existing_workspace(
138 &paths,
139 &open_options,
140 &SerializedWorkspaceLocation::Remote(connection_options.clone()),
141 cx,
142 )
143 .await;
144
145 if let Some(existing) = existing {
146 let remote_connection = existing
147 .update(cx, |workspace, _, cx| {
148 workspace
149 .project()
150 .read(cx)
151 .remote_client()
152 .and_then(|client| client.read(cx).remote_connection())
153 })?
154 .ok_or_else(|| anyhow::anyhow!("no remote connection for existing remote workspace"))?;
155
156 let (resolved_paths, paths_with_positions) =
157 determine_paths_with_positions(&remote_connection, paths).await;
158
159 let open_results = existing
160 .update(cx, |workspace, window, cx| {
161 window.activate_window();
162 workspace.open_paths(
163 resolved_paths,
164 OpenOptions {
165 visible: Some(open_visible),
166 ..Default::default()
167 },
168 None,
169 window,
170 cx,
171 )
172 })?
173 .await;
174
175 _ = existing.update(cx, |workspace, _, cx| {
176 for item in open_results.iter().flatten() {
177 if let Err(e) = item {
178 workspace.show_error(&e, cx);
179 }
180 }
181 });
182
183 let items = open_results
184 .into_iter()
185 .map(|r| r.and_then(|r| r.ok()))
186 .collect::<Vec<_>>();
187 navigate_to_positions(&existing, items, &paths_with_positions, cx);
188
189 return Ok(());
190 }
191
192 let window = if let Some(window) = open_options.replace_window {
193 window
194 } else {
195 let workspace_position = cx
196 .update(|cx| {
197 workspace::remote_workspace_position_from_db(connection_options.clone(), &paths, cx)
198 })
199 .await
200 .context("fetching remote workspace position from db")?;
201
202 let mut options =
203 cx.update(|cx| (app_state.build_window_options)(workspace_position.display, cx));
204 options.window_bounds = workspace_position.window_bounds;
205
206 cx.open_window(options, |window, cx| {
207 let project = project::Project::local(
208 app_state.client.clone(),
209 app_state.node_runtime.clone(),
210 app_state.user_store.clone(),
211 app_state.languages.clone(),
212 app_state.fs.clone(),
213 None,
214 project::LocalProjectFlags {
215 init_worktree_trust: false,
216 ..Default::default()
217 },
218 cx,
219 );
220 cx.new(|cx| {
221 let mut workspace = Workspace::new(None, project, app_state.clone(), window, cx);
222 workspace.centered_layout = workspace_position.centered_layout;
223 workspace
224 })
225 })?
226 };
227
228 loop {
229 let (cancel_tx, mut cancel_rx) = oneshot::channel();
230 let delegate = window.update(cx, {
231 let paths = paths.clone();
232 let connection_options = connection_options.clone();
233 move |workspace, window, cx| {
234 window.activate_window();
235 workspace.hide_modal(window, cx);
236 workspace.toggle_modal(window, cx, |window, cx| {
237 RemoteConnectionModal::new(&connection_options, paths, window, cx)
238 });
239
240 let ui = workspace
241 .active_modal::<RemoteConnectionModal>(cx)?
242 .read(cx)
243 .prompt
244 .clone();
245
246 ui.update(cx, |ui, _cx| {
247 ui.set_cancellation_tx(cancel_tx);
248 });
249
250 Some(Arc::new(RemoteClientDelegate::new(
251 window.window_handle(),
252 ui.downgrade(),
253 if let RemoteConnectionOptions::Ssh(options) = &connection_options {
254 options
255 .password
256 .as_deref()
257 .and_then(|pw| EncryptedPassword::try_from(pw).ok())
258 } else {
259 None
260 },
261 )))
262 }
263 })?;
264
265 let Some(delegate) = delegate else { break };
266
267 let connection = remote::connect(connection_options.clone(), delegate.clone(), cx);
268 let connection = select! {
269 _ = cancel_rx => {
270 window
271 .update(cx, |workspace, _, cx| {
272 if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
273 ui.update(cx, |modal, cx| modal.finished(cx))
274 }
275 })
276 .ok();
277
278 break;
279 },
280 result = connection.fuse() => result,
281 };
282 let remote_connection = match connection {
283 Ok(connection) => connection,
284 Err(e) => {
285 window
286 .update(cx, |workspace, _, cx| {
287 if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
288 ui.update(cx, |modal, cx| modal.finished(cx))
289 }
290 })
291 .ok();
292 log::error!("Failed to open project: {e:#}");
293 let response = window
294 .update(cx, |_, window, cx| {
295 window.prompt(
296 PromptLevel::Critical,
297 match connection_options {
298 RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
299 RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
300 RemoteConnectionOptions::Docker(_) => {
301 "Failed to connect to Dev Container"
302 }
303 #[cfg(any(test, feature = "test-support"))]
304 RemoteConnectionOptions::Mock(_) => {
305 "Failed to connect to mock server"
306 }
307 },
308 Some(&format!("{e:#}")),
309 &["Retry", "Cancel"],
310 cx,
311 )
312 })?
313 .await;
314
315 if response == Ok(0) {
316 continue;
317 }
318
319 if created_new_window {
320 window
321 .update(cx, |_, window, _| window.remove_window())
322 .ok();
323 }
324 return Ok(());
325 }
326 };
327
328 let (paths, paths_with_positions) =
329 determine_paths_with_positions(&remote_connection, paths.clone()).await;
330
331 let opened_items = cx
332 .update(|cx| {
333 workspace::open_remote_project_with_new_connection(
334 window,
335 remote_connection,
336 cancel_rx,
337 delegate.clone(),
338 app_state.clone(),
339 paths.clone(),
340 cx,
341 )
342 })
343 .await;
344
345 window
346 .update(cx, |workspace, _, cx| {
347 if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
348 ui.update(cx, |modal, cx| modal.finished(cx))
349 }
350 })
351 .ok();
352
353 match opened_items {
354 Err(e) => {
355 log::error!("Failed to open project: {e:#}");
356 let response = window
357 .update(cx, |_, window, cx| {
358 window.prompt(
359 PromptLevel::Critical,
360 match connection_options {
361 RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
362 RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
363 RemoteConnectionOptions::Docker(_) => {
364 "Failed to connect to Dev Container"
365 }
366 #[cfg(any(test, feature = "test-support"))]
367 RemoteConnectionOptions::Mock(_) => {
368 "Failed to connect to mock server"
369 }
370 },
371 Some(&format!("{e:#}")),
372 &["Retry", "Cancel"],
373 cx,
374 )
375 })?
376 .await;
377 if response == Ok(0) {
378 continue;
379 }
380
381 window
382 .update(cx, |workspace, window, cx| {
383 if created_new_window {
384 window.remove_window();
385 }
386 trusted_worktrees::track_worktree_trust(
387 workspace.project().read(cx).worktree_store(),
388 None,
389 None,
390 None,
391 cx,
392 );
393 })
394 .ok();
395 }
396
397 Ok(items) => {
398 navigate_to_positions(&window, items, &paths_with_positions, cx);
399 }
400 }
401
402 break;
403 }
404
405 window
406 .update(cx, |workspace, _, cx| {
407 if let Some(client) = workspace.project().read(cx).remote_client() {
408 if let Some(extension_store) = ExtensionStore::try_global(cx) {
409 extension_store
410 .update(cx, |store, cx| store.register_remote_client(client, cx));
411 }
412 }
413 })
414 .ok();
415 Ok(())
416}
417
418pub fn navigate_to_positions(
419 window: &WindowHandle<Workspace>,
420 items: impl IntoIterator<Item = Option<Box<dyn workspace::item::ItemHandle>>>,
421 positions: &[PathWithPosition],
422 cx: &mut AsyncApp,
423) {
424 for (item, path) in items.into_iter().zip(positions) {
425 let Some(item) = item else {
426 continue;
427 };
428 let Some(row) = path.row else {
429 continue;
430 };
431 if let Some(active_editor) = item.downcast::<Editor>() {
432 window
433 .update(cx, |_, window, cx| {
434 active_editor.update(cx, |editor, cx| {
435 let row = row.saturating_sub(1);
436 let col = path.column.unwrap_or(0).saturating_sub(1);
437 editor.go_to_singleton_buffer_point(Point::new(row, col), window, cx);
438 });
439 })
440 .ok();
441 }
442 }
443}
444
445pub(crate) async fn determine_paths_with_positions(
446 remote_connection: &Arc<dyn RemoteConnection>,
447 mut paths: Vec<PathBuf>,
448) -> (Vec<PathBuf>, Vec<PathWithPosition>) {
449 let mut paths_with_positions = Vec::<PathWithPosition>::new();
450 for path in &mut paths {
451 if let Some(path_str) = path.to_str() {
452 let path_with_position = PathWithPosition::parse_str(&path_str);
453 if path_with_position.row.is_some() {
454 if !path_exists(&remote_connection, &path).await {
455 *path = path_with_position.path.clone();
456 paths_with_positions.push(path_with_position);
457 continue;
458 }
459 }
460 }
461 paths_with_positions.push(PathWithPosition::from_path(path.clone()))
462 }
463 (paths, paths_with_positions)
464}
465
466async fn path_exists(connection: &Arc<dyn RemoteConnection>, path: &Path) -> bool {
467 let Ok(command) = connection.build_command(
468 Some("test".to_string()),
469 &["-e".to_owned(), path.to_string_lossy().to_string()],
470 &Default::default(),
471 None,
472 None,
473 Interactive::No,
474 ) else {
475 return false;
476 };
477 let Ok(mut child) = util::command::new_smol_command(command.program)
478 .args(command.args)
479 .envs(command.env)
480 .spawn()
481 else {
482 return false;
483 };
484 child.status().await.is_ok_and(|status| status.success())
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490 use extension::ExtensionHostProxy;
491 use fs::FakeFs;
492 use gpui::{AppContext, TestAppContext};
493 use http_client::BlockedHttpClient;
494 use node_runtime::NodeRuntime;
495 use remote::RemoteClient;
496 use remote_server::{HeadlessAppState, HeadlessProject};
497 use serde_json::json;
498 use util::path;
499
500 #[gpui::test]
501 async fn test_open_remote_project_with_mock_connection(
502 cx: &mut TestAppContext,
503 server_cx: &mut TestAppContext,
504 ) {
505 let app_state = init_test(cx);
506 let executor = cx.executor();
507
508 cx.update(|cx| {
509 release_channel::init(semver::Version::new(0, 0, 0), cx);
510 });
511 server_cx.update(|cx| {
512 release_channel::init(semver::Version::new(0, 0, 0), cx);
513 });
514
515 let (opts, server_session, connect_guard) = RemoteClient::fake_server(cx, server_cx);
516
517 let remote_fs = FakeFs::new(server_cx.executor());
518 remote_fs
519 .insert_tree(
520 path!("/project"),
521 json!({
522 "src": {
523 "main.rs": "fn main() {}",
524 },
525 "README.md": "# Test Project",
526 }),
527 )
528 .await;
529
530 server_cx.update(HeadlessProject::init);
531 let http_client = Arc::new(BlockedHttpClient);
532 let node_runtime = NodeRuntime::unavailable();
533 let languages = Arc::new(language::LanguageRegistry::new(server_cx.executor()));
534 let proxy = Arc::new(ExtensionHostProxy::new());
535
536 let _headless = server_cx.new(|cx| {
537 HeadlessProject::new(
538 HeadlessAppState {
539 session: server_session,
540 fs: remote_fs.clone(),
541 http_client,
542 node_runtime,
543 languages,
544 extension_host_proxy: proxy,
545 },
546 false,
547 cx,
548 )
549 });
550
551 drop(connect_guard);
552
553 let paths = vec![PathBuf::from(path!("/project"))];
554 let open_options = workspace::OpenOptions::default();
555
556 let mut async_cx = cx.to_async();
557 let result = open_remote_project(opts, paths, app_state, open_options, &mut async_cx).await;
558
559 executor.run_until_parked();
560
561 assert!(result.is_ok(), "open_remote_project should succeed");
562
563 let windows = cx.update(|cx| cx.windows().len());
564 assert_eq!(windows, 1, "Should have opened a window");
565
566 let workspace_handle = cx.update(|cx| cx.windows()[0].downcast::<Workspace>().unwrap());
567
568 workspace_handle
569 .update(cx, |workspace, _, cx| {
570 let project = workspace.project().read(cx);
571 assert!(project.is_remote(), "Project should be a remote project");
572 })
573 .unwrap();
574 }
575
576 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
577 cx.update(|cx| {
578 let state = AppState::test(cx);
579 crate::init(cx);
580 editor::init(cx);
581 state
582 })
583 }
584}