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