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, 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 = if let Some(window) = open_options.replace_window {
135 window
136 } else {
137 let workspace_position = cx
138 .update(|cx| {
139 workspace::remote_workspace_position_from_db(connection_options.clone(), &paths, cx)
140 })
141 .await
142 .context("fetching remote workspace position from db")?;
143
144 let mut options =
145 cx.update(|cx| (app_state.build_window_options)(workspace_position.display, cx));
146 options.window_bounds = workspace_position.window_bounds;
147
148 cx.open_window(options, |window, cx| {
149 let project = project::Project::local(
150 app_state.client.clone(),
151 app_state.node_runtime.clone(),
152 app_state.user_store.clone(),
153 app_state.languages.clone(),
154 app_state.fs.clone(),
155 None,
156 project::LocalProjectFlags {
157 init_worktree_trust: false,
158 ..Default::default()
159 },
160 cx,
161 );
162 cx.new(|cx| {
163 let mut workspace = Workspace::new(None, project, app_state.clone(), window, cx);
164 workspace.centered_layout = workspace_position.centered_layout;
165 workspace
166 })
167 })?
168 };
169
170 loop {
171 let (cancel_tx, mut cancel_rx) = oneshot::channel();
172 let delegate = window.update(cx, {
173 let paths = paths.clone();
174 let connection_options = connection_options.clone();
175 move |workspace, window, cx| {
176 window.activate_window();
177 workspace.hide_modal(window, cx);
178 workspace.toggle_modal(window, cx, |window, cx| {
179 RemoteConnectionModal::new(&connection_options, paths, window, cx)
180 });
181
182 let ui = workspace
183 .active_modal::<RemoteConnectionModal>(cx)?
184 .read(cx)
185 .prompt
186 .clone();
187
188 ui.update(cx, |ui, _cx| {
189 ui.set_cancellation_tx(cancel_tx);
190 });
191
192 Some(Arc::new(RemoteClientDelegate::new(
193 window.window_handle(),
194 ui.downgrade(),
195 if let RemoteConnectionOptions::Ssh(options) = &connection_options {
196 options
197 .password
198 .as_deref()
199 .and_then(|pw| EncryptedPassword::try_from(pw).ok())
200 } else {
201 None
202 },
203 )))
204 }
205 })?;
206
207 let Some(delegate) = delegate else { break };
208
209 let connection = remote::connect(connection_options.clone(), delegate.clone(), cx);
210 let connection = select! {
211 _ = cancel_rx => {
212 window
213 .update(cx, |workspace, _, cx| {
214 if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
215 ui.update(cx, |modal, cx| modal.finished(cx))
216 }
217 })
218 .ok();
219
220 break;
221 },
222 result = connection.fuse() => result,
223 };
224 let remote_connection = match connection {
225 Ok(connection) => connection,
226 Err(e) => {
227 window
228 .update(cx, |workspace, _, cx| {
229 if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
230 ui.update(cx, |modal, cx| modal.finished(cx))
231 }
232 })
233 .ok();
234 log::error!("Failed to open project: {e:#}");
235 let response = window
236 .update(cx, |_, window, cx| {
237 window.prompt(
238 PromptLevel::Critical,
239 match connection_options {
240 RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
241 RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
242 RemoteConnectionOptions::Docker(_) => {
243 "Failed to connect to Dev Container"
244 }
245 #[cfg(any(test, feature = "test-support"))]
246 RemoteConnectionOptions::Mock(_) => {
247 "Failed to connect to mock server"
248 }
249 },
250 Some(&format!("{e:#}")),
251 &["Retry", "Cancel"],
252 cx,
253 )
254 })?
255 .await;
256
257 if response == Ok(0) {
258 continue;
259 }
260
261 if created_new_window {
262 window
263 .update(cx, |_, window, _| window.remove_window())
264 .ok();
265 }
266 return Ok(());
267 }
268 };
269
270 let (paths, paths_with_positions) =
271 determine_paths_with_positions(&remote_connection, paths.clone()).await;
272
273 let opened_items = cx
274 .update(|cx| {
275 workspace::open_remote_project_with_new_connection(
276 window,
277 remote_connection,
278 cancel_rx,
279 delegate.clone(),
280 app_state.clone(),
281 paths.clone(),
282 cx,
283 )
284 })
285 .await;
286
287 window
288 .update(cx, |workspace, _, cx| {
289 if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
290 ui.update(cx, |modal, cx| modal.finished(cx))
291 }
292 })
293 .ok();
294
295 match opened_items {
296 Err(e) => {
297 log::error!("Failed to open project: {e:#}");
298 let response = window
299 .update(cx, |_, window, cx| {
300 window.prompt(
301 PromptLevel::Critical,
302 match connection_options {
303 RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
304 RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
305 RemoteConnectionOptions::Docker(_) => {
306 "Failed to connect to Dev Container"
307 }
308 #[cfg(any(test, feature = "test-support"))]
309 RemoteConnectionOptions::Mock(_) => {
310 "Failed to connect to mock server"
311 }
312 },
313 Some(&format!("{e:#}")),
314 &["Retry", "Cancel"],
315 cx,
316 )
317 })?
318 .await;
319 if response == Ok(0) {
320 continue;
321 }
322
323 window
324 .update(cx, |workspace, window, cx| {
325 if created_new_window {
326 window.remove_window();
327 }
328 trusted_worktrees::track_worktree_trust(
329 workspace.project().read(cx).worktree_store(),
330 None,
331 None,
332 None,
333 cx,
334 );
335 })
336 .ok();
337 }
338
339 Ok(items) => {
340 for (item, path) in items.into_iter().zip(paths_with_positions) {
341 let Some(item) = item else {
342 continue;
343 };
344 let Some(row) = path.row else {
345 continue;
346 };
347 if let Some(active_editor) = item.downcast::<Editor>() {
348 window
349 .update(cx, |_, window, cx| {
350 active_editor.update(cx, |editor, cx| {
351 let row = row.saturating_sub(1);
352 let col = path.column.unwrap_or(0).saturating_sub(1);
353 editor.go_to_singleton_buffer_point(
354 Point::new(row, col),
355 window,
356 cx,
357 );
358 });
359 })
360 .ok();
361 }
362 }
363 }
364 }
365
366 break;
367 }
368
369 window
370 .update(cx, |workspace, _, cx| {
371 if let Some(client) = workspace.project().read(cx).remote_client() {
372 if let Some(extension_store) = ExtensionStore::try_global(cx) {
373 extension_store
374 .update(cx, |store, cx| store.register_remote_client(client, cx));
375 }
376 }
377 })
378 .ok();
379 Ok(())
380}
381
382pub(crate) async fn determine_paths_with_positions(
383 remote_connection: &Arc<dyn RemoteConnection>,
384 mut paths: Vec<PathBuf>,
385) -> (Vec<PathBuf>, Vec<PathWithPosition>) {
386 let mut paths_with_positions = Vec::<PathWithPosition>::new();
387 for path in &mut paths {
388 if let Some(path_str) = path.to_str() {
389 let path_with_position = PathWithPosition::parse_str(&path_str);
390 if path_with_position.row.is_some() {
391 if !path_exists(&remote_connection, &path).await {
392 *path = path_with_position.path.clone();
393 paths_with_positions.push(path_with_position);
394 continue;
395 }
396 }
397 }
398 paths_with_positions.push(PathWithPosition::from_path(path.clone()))
399 }
400 (paths, paths_with_positions)
401}
402
403async fn path_exists(connection: &Arc<dyn RemoteConnection>, path: &Path) -> bool {
404 let Ok(command) = connection.build_command(
405 Some("test".to_string()),
406 &["-e".to_owned(), path.to_string_lossy().to_string()],
407 &Default::default(),
408 None,
409 None,
410 Interactive::No,
411 ) else {
412 return false;
413 };
414 let Ok(mut child) = util::command::new_smol_command(command.program)
415 .args(command.args)
416 .envs(command.env)
417 .spawn()
418 else {
419 return false;
420 };
421 child.status().await.is_ok_and(|status| status.success())
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427 use extension::ExtensionHostProxy;
428 use fs::FakeFs;
429 use gpui::{AppContext, TestAppContext};
430 use http_client::BlockedHttpClient;
431 use node_runtime::NodeRuntime;
432 use remote::RemoteClient;
433 use remote_server::{HeadlessAppState, HeadlessProject};
434 use serde_json::json;
435 use util::path;
436
437 #[gpui::test]
438 async fn test_open_remote_project_with_mock_connection(
439 cx: &mut TestAppContext,
440 server_cx: &mut TestAppContext,
441 ) {
442 let app_state = init_test(cx);
443 let executor = cx.executor();
444
445 cx.update(|cx| {
446 release_channel::init(semver::Version::new(0, 0, 0), cx);
447 });
448 server_cx.update(|cx| {
449 release_channel::init(semver::Version::new(0, 0, 0), cx);
450 });
451
452 let (opts, server_session, connect_guard) = RemoteClient::fake_server(cx, server_cx);
453
454 let remote_fs = FakeFs::new(server_cx.executor());
455 remote_fs
456 .insert_tree(
457 path!("/project"),
458 json!({
459 "src": {
460 "main.rs": "fn main() {}",
461 },
462 "README.md": "# Test Project",
463 }),
464 )
465 .await;
466
467 server_cx.update(HeadlessProject::init);
468 let http_client = Arc::new(BlockedHttpClient);
469 let node_runtime = NodeRuntime::unavailable();
470 let languages = Arc::new(language::LanguageRegistry::new(server_cx.executor()));
471 let proxy = Arc::new(ExtensionHostProxy::new());
472
473 let _headless = server_cx.new(|cx| {
474 HeadlessProject::new(
475 HeadlessAppState {
476 session: server_session,
477 fs: remote_fs.clone(),
478 http_client,
479 node_runtime,
480 languages,
481 extension_host_proxy: proxy,
482 },
483 false,
484 cx,
485 )
486 });
487
488 drop(connect_guard);
489
490 let paths = vec![PathBuf::from(path!("/project"))];
491 let open_options = workspace::OpenOptions::default();
492
493 let mut async_cx = cx.to_async();
494 let result = open_remote_project(opts, paths, app_state, open_options, &mut async_cx).await;
495
496 executor.run_until_parked();
497
498 assert!(result.is_ok(), "open_remote_project should succeed");
499
500 let windows = cx.update(|cx| cx.windows().len());
501 assert_eq!(windows, 1, "Should have opened a window");
502
503 let workspace_handle = cx.update(|cx| cx.windows()[0].downcast::<Workspace>().unwrap());
504
505 workspace_handle
506 .update(cx, |workspace, _, cx| {
507 let project = workspace.project().read(cx);
508 assert!(project.is_remote(), "Project should be a remote project");
509 })
510 .unwrap();
511 }
512
513 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
514 cx.update(|cx| {
515 let state = AppState::test(cx);
516 crate::init(cx);
517 editor::init(cx);
518 state
519 })
520 }
521}