remote_editing_collaboration_tests.rs

  1use crate::tests::TestServer;
  2use call::ActiveCall;
  3use collections::{HashMap, HashSet};
  4
  5use extension::ExtensionHostProxy;
  6use fs::{FakeFs, Fs as _, RemoveOptions};
  7use futures::StreamExt as _;
  8use gpui::{
  9    AppContext as _, BackgroundExecutor, SemanticVersion, TestAppContext, UpdateGlobal as _,
 10};
 11use http_client::BlockedHttpClient;
 12use language::{
 13    FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, LanguageRegistry,
 14    language_settings::{
 15        AllLanguageSettings, Formatter, FormatterList, PrettierSettings, SelectedFormatter,
 16        language_settings,
 17    },
 18    tree_sitter_typescript,
 19};
 20use node_runtime::NodeRuntime;
 21use project::{
 22    ProjectPath,
 23    lsp_store::{FormatTrigger, LspFormatTarget},
 24};
 25use remote::SshRemoteClient;
 26use remote_server::{HeadlessAppState, HeadlessProject};
 27use serde_json::json;
 28use settings::SettingsStore;
 29use std::{path::Path, sync::Arc};
 30use util::{path, separator};
 31
 32#[gpui::test(iterations = 10)]
 33async fn test_sharing_an_ssh_remote_project(
 34    cx_a: &mut TestAppContext,
 35    cx_b: &mut TestAppContext,
 36    server_cx: &mut TestAppContext,
 37) {
 38    let executor = cx_a.executor();
 39    cx_a.update(|cx| {
 40        release_channel::init(SemanticVersion::default(), cx);
 41    });
 42    server_cx.update(|cx| {
 43        release_channel::init(SemanticVersion::default(), cx);
 44    });
 45    let mut server = TestServer::start(executor.clone()).await;
 46    let client_a = server.create_client(cx_a, "user_a").await;
 47    let client_b = server.create_client(cx_b, "user_b").await;
 48    server
 49        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
 50        .await;
 51
 52    // Set up project on remote FS
 53    let (opts, server_ssh) = SshRemoteClient::fake_server(cx_a, server_cx);
 54    let remote_fs = FakeFs::new(server_cx.executor());
 55    remote_fs
 56        .insert_tree(
 57            path!("/code"),
 58            json!({
 59                "project1": {
 60                    ".zed": {
 61                        "settings.json": r#"{"languages":{"Rust":{"language_servers":["override-rust-analyzer"]}}}"#
 62                    },
 63                    "README.md": "# project 1",
 64                    "src": {
 65                        "lib.rs": "fn one() -> usize { 1 }"
 66                    }
 67                },
 68                "project2": {
 69                    "README.md": "# project 2",
 70                },
 71            }),
 72        )
 73        .await;
 74
 75    // User A connects to the remote project via SSH.
 76    server_cx.update(HeadlessProject::init);
 77    let remote_http_client = Arc::new(BlockedHttpClient);
 78    let node = NodeRuntime::unavailable();
 79    let languages = Arc::new(LanguageRegistry::new(server_cx.executor()));
 80    let _headless_project = server_cx.new(|cx| {
 81        client::init_settings(cx);
 82        HeadlessProject::new(
 83            HeadlessAppState {
 84                session: server_ssh,
 85                fs: remote_fs.clone(),
 86                http_client: remote_http_client,
 87                node_runtime: node,
 88                languages,
 89                extension_host_proxy: Arc::new(ExtensionHostProxy::new()),
 90            },
 91            cx,
 92        )
 93    });
 94
 95    let client_ssh = SshRemoteClient::fake_client(opts, cx_a).await;
 96    let (project_a, worktree_id) = client_a
 97        .build_ssh_project(path!("/code/project1"), client_ssh, cx_a)
 98        .await;
 99
100    // While the SSH worktree is being scanned, user A shares the remote project.
101    let active_call_a = cx_a.read(ActiveCall::global);
102    let project_id = active_call_a
103        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
104        .await
105        .unwrap();
106
107    // User B joins the project.
108    let project_b = client_b.join_remote_project(project_id, cx_b).await;
109    let worktree_b = project_b
110        .update(cx_b, |project, cx| project.worktree_for_id(worktree_id, cx))
111        .unwrap();
112
113    let worktree_a = project_a
114        .update(cx_a, |project, cx| project.worktree_for_id(worktree_id, cx))
115        .unwrap();
116
117    executor.run_until_parked();
118
119    worktree_a.update(cx_a, |worktree, _cx| {
120        assert_eq!(
121            worktree.paths().map(Arc::as_ref).collect::<Vec<_>>(),
122            vec![
123                Path::new(".zed"),
124                Path::new(".zed/settings.json"),
125                Path::new("README.md"),
126                Path::new("src"),
127                Path::new("src/lib.rs"),
128            ]
129        );
130    });
131
132    worktree_b.update(cx_b, |worktree, _cx| {
133        assert_eq!(
134            worktree.paths().map(Arc::as_ref).collect::<Vec<_>>(),
135            vec![
136                Path::new(".zed"),
137                Path::new(".zed/settings.json"),
138                Path::new("README.md"),
139                Path::new("src"),
140                Path::new("src/lib.rs"),
141            ]
142        );
143    });
144
145    // User B can open buffers in the remote project.
146    let buffer_b = project_b
147        .update(cx_b, |project, cx| {
148            project.open_buffer((worktree_id, "src/lib.rs"), cx)
149        })
150        .await
151        .unwrap();
152    buffer_b.update(cx_b, |buffer, cx| {
153        assert_eq!(buffer.text(), "fn one() -> usize { 1 }");
154        let ix = buffer.text().find('1').unwrap();
155        buffer.edit([(ix..ix + 1, "100")], None, cx);
156    });
157
158    executor.run_until_parked();
159
160    cx_b.read(|cx| {
161        let file = buffer_b.read(cx).file();
162        assert_eq!(
163            language_settings(Some("Rust".into()), file, cx).language_servers,
164            ["override-rust-analyzer".to_string()]
165        )
166    });
167
168    project_b
169        .update(cx_b, |project, cx| {
170            project.save_buffer_as(
171                buffer_b.clone(),
172                ProjectPath {
173                    worktree_id: worktree_id.to_owned(),
174                    path: Arc::from(Path::new("src/renamed.rs")),
175                },
176                cx,
177            )
178        })
179        .await
180        .unwrap();
181    assert_eq!(
182        remote_fs
183            .load(path!("/code/project1/src/renamed.rs").as_ref())
184            .await
185            .unwrap(),
186        "fn one() -> usize { 100 }"
187    );
188    cx_b.run_until_parked();
189    cx_b.update(|cx| {
190        assert_eq!(
191            buffer_b
192                .read(cx)
193                .file()
194                .unwrap()
195                .path()
196                .to_string_lossy()
197                .to_string(),
198            separator!("src/renamed.rs").to_string()
199        );
200    });
201}
202
203#[gpui::test]
204async fn test_ssh_collaboration_git_branches(
205    executor: BackgroundExecutor,
206    cx_a: &mut TestAppContext,
207    cx_b: &mut TestAppContext,
208    server_cx: &mut TestAppContext,
209) {
210    cx_a.set_name("a");
211    cx_b.set_name("b");
212    server_cx.set_name("server");
213
214    cx_a.update(|cx| {
215        release_channel::init(SemanticVersion::default(), cx);
216    });
217    server_cx.update(|cx| {
218        release_channel::init(SemanticVersion::default(), cx);
219    });
220
221    let mut server = TestServer::start(executor.clone()).await;
222    let client_a = server.create_client(cx_a, "user_a").await;
223    let client_b = server.create_client(cx_b, "user_b").await;
224    server
225        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
226        .await;
227
228    // Set up project on remote FS
229    let (opts, server_ssh) = SshRemoteClient::fake_server(cx_a, server_cx);
230    let remote_fs = FakeFs::new(server_cx.executor());
231    remote_fs
232        .insert_tree("/project", serde_json::json!({ ".git":{} }))
233        .await;
234
235    let branches = ["main", "dev", "feature-1"];
236    let branches_set = branches
237        .iter()
238        .map(ToString::to_string)
239        .collect::<HashSet<_>>();
240    remote_fs.insert_branches(Path::new("/project/.git"), &branches);
241
242    // User A connects to the remote project via SSH.
243    server_cx.update(HeadlessProject::init);
244    let remote_http_client = Arc::new(BlockedHttpClient);
245    let node = NodeRuntime::unavailable();
246    let languages = Arc::new(LanguageRegistry::new(server_cx.executor()));
247    let headless_project = server_cx.new(|cx| {
248        client::init_settings(cx);
249        HeadlessProject::new(
250            HeadlessAppState {
251                session: server_ssh,
252                fs: remote_fs.clone(),
253                http_client: remote_http_client,
254                node_runtime: node,
255                languages,
256                extension_host_proxy: Arc::new(ExtensionHostProxy::new()),
257            },
258            cx,
259        )
260    });
261
262    let client_ssh = SshRemoteClient::fake_client(opts, cx_a).await;
263    let (project_a, _) = client_a
264        .build_ssh_project("/project", client_ssh, cx_a)
265        .await;
266
267    // While the SSH worktree is being scanned, user A shares the remote project.
268    let active_call_a = cx_a.read(ActiveCall::global);
269    let project_id = active_call_a
270        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
271        .await
272        .unwrap();
273
274    // User B joins the project.
275    let project_b = client_b.join_remote_project(project_id, cx_b).await;
276
277    // Give client A sometime to see that B has joined, and that the headless server
278    // has some git repositories
279    executor.run_until_parked();
280
281    let repo_b = cx_b.update(|cx| project_b.read(cx).active_repository(cx).unwrap());
282
283    let branches_b = cx_b
284        .update(|cx| repo_b.update(cx, |repo_b, _cx| repo_b.branches()))
285        .await
286        .unwrap()
287        .unwrap();
288
289    let new_branch = branches[2];
290
291    let branches_b = branches_b
292        .into_iter()
293        .map(|branch| branch.name.to_string())
294        .collect::<HashSet<_>>();
295
296    assert_eq!(&branches_b, &branches_set);
297
298    cx_b.update(|cx| {
299        repo_b.update(cx, |repo_b, _cx| {
300            repo_b.change_branch(new_branch.to_string())
301        })
302    })
303    .await
304    .unwrap()
305    .unwrap();
306
307    executor.run_until_parked();
308
309    let server_branch = server_cx.update(|cx| {
310        headless_project.update(cx, |headless_project, cx| {
311            headless_project.git_store.update(cx, |git_store, cx| {
312                git_store
313                    .repositories()
314                    .values()
315                    .next()
316                    .unwrap()
317                    .read(cx)
318                    .branch
319                    .as_ref()
320                    .unwrap()
321                    .clone()
322            })
323        })
324    });
325
326    assert_eq!(server_branch.name, branches[2]);
327
328    // Also try creating a new branch
329    cx_b.update(|cx| {
330        repo_b.update(cx, |repo_b, _cx| {
331            repo_b.create_branch("totally-new-branch".to_string())
332        })
333    })
334    .await
335    .unwrap()
336    .unwrap();
337
338    cx_b.update(|cx| {
339        repo_b.update(cx, |repo_b, _cx| {
340            repo_b.change_branch("totally-new-branch".to_string())
341        })
342    })
343    .await
344    .unwrap()
345    .unwrap();
346
347    executor.run_until_parked();
348
349    let server_branch = server_cx.update(|cx| {
350        headless_project.update(cx, |headless_project, cx| {
351            headless_project.git_store.update(cx, |git_store, cx| {
352                git_store
353                    .repositories()
354                    .values()
355                    .next()
356                    .unwrap()
357                    .read(cx)
358                    .branch
359                    .as_ref()
360                    .unwrap()
361                    .clone()
362            })
363        })
364    });
365
366    assert_eq!(server_branch.name, "totally-new-branch");
367
368    // Remove the git repository and check that all participants get the update.
369    remote_fs
370        .remove_dir("/project/.git".as_ref(), RemoveOptions::default())
371        .await
372        .unwrap();
373    executor.run_until_parked();
374
375    project_a.update(cx_a, |project, cx| {
376        pretty_assertions::assert_eq!(
377            project.git_store().read(cx).repo_snapshots(cx),
378            HashMap::default()
379        );
380    });
381    project_b.update(cx_b, |project, cx| {
382        pretty_assertions::assert_eq!(
383            project.git_store().read(cx).repo_snapshots(cx),
384            HashMap::default()
385        );
386    });
387}
388
389#[gpui::test]
390async fn test_ssh_collaboration_formatting_with_prettier(
391    executor: BackgroundExecutor,
392    cx_a: &mut TestAppContext,
393    cx_b: &mut TestAppContext,
394    server_cx: &mut TestAppContext,
395) {
396    cx_a.set_name("a");
397    cx_b.set_name("b");
398    server_cx.set_name("server");
399
400    cx_a.update(|cx| {
401        release_channel::init(SemanticVersion::default(), cx);
402    });
403    server_cx.update(|cx| {
404        release_channel::init(SemanticVersion::default(), cx);
405    });
406
407    let mut server = TestServer::start(executor.clone()).await;
408    let client_a = server.create_client(cx_a, "user_a").await;
409    let client_b = server.create_client(cx_b, "user_b").await;
410    server
411        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
412        .await;
413
414    let (opts, server_ssh) = SshRemoteClient::fake_server(cx_a, server_cx);
415    let remote_fs = FakeFs::new(server_cx.executor());
416    let buffer_text = "let one = \"two\"";
417    let prettier_format_suffix = project::TEST_PRETTIER_FORMAT_SUFFIX;
418    remote_fs
419        .insert_tree(
420            path!("/project"),
421            serde_json::json!({ "a.ts": buffer_text }),
422        )
423        .await;
424
425    let test_plugin = "test_plugin";
426    let ts_lang = Arc::new(Language::new(
427        LanguageConfig {
428            name: "TypeScript".into(),
429            matcher: LanguageMatcher {
430                path_suffixes: vec!["ts".to_string()],
431                ..LanguageMatcher::default()
432            },
433            ..LanguageConfig::default()
434        },
435        Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()),
436    ));
437    client_a.language_registry().add(ts_lang.clone());
438    client_b.language_registry().add(ts_lang.clone());
439
440    let languages = Arc::new(LanguageRegistry::new(server_cx.executor()));
441    let mut fake_language_servers = languages.register_fake_lsp(
442        "TypeScript",
443        FakeLspAdapter {
444            prettier_plugins: vec![test_plugin],
445            ..Default::default()
446        },
447    );
448
449    // User A connects to the remote project via SSH.
450    server_cx.update(HeadlessProject::init);
451    let remote_http_client = Arc::new(BlockedHttpClient);
452    let _headless_project = server_cx.new(|cx| {
453        client::init_settings(cx);
454        HeadlessProject::new(
455            HeadlessAppState {
456                session: server_ssh,
457                fs: remote_fs.clone(),
458                http_client: remote_http_client,
459                node_runtime: NodeRuntime::unavailable(),
460                languages,
461                extension_host_proxy: Arc::new(ExtensionHostProxy::new()),
462            },
463            cx,
464        )
465    });
466
467    let client_ssh = SshRemoteClient::fake_client(opts, cx_a).await;
468    let (project_a, worktree_id) = client_a
469        .build_ssh_project(path!("/project"), client_ssh, cx_a)
470        .await;
471
472    // While the SSH worktree is being scanned, user A shares the remote project.
473    let active_call_a = cx_a.read(ActiveCall::global);
474    let project_id = active_call_a
475        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
476        .await
477        .unwrap();
478
479    // User B joins the project.
480    let project_b = client_b.join_remote_project(project_id, cx_b).await;
481    executor.run_until_parked();
482
483    // Opens the buffer and formats it
484    let (buffer_b, _handle) = project_b
485        .update(cx_b, |p, cx| {
486            p.open_buffer_with_lsp((worktree_id, "a.ts"), cx)
487        })
488        .await
489        .expect("user B opens buffer for formatting");
490
491    cx_a.update(|cx| {
492        SettingsStore::update_global(cx, |store, cx| {
493            store.update_user_settings::<AllLanguageSettings>(cx, |file| {
494                file.defaults.formatter = Some(SelectedFormatter::Auto);
495                file.defaults.prettier = Some(PrettierSettings {
496                    allowed: true,
497                    ..PrettierSettings::default()
498                });
499            });
500        });
501    });
502    cx_b.update(|cx| {
503        SettingsStore::update_global(cx, |store, cx| {
504            store.update_user_settings::<AllLanguageSettings>(cx, |file| {
505                file.defaults.formatter = Some(SelectedFormatter::List(FormatterList(
506                    vec![Formatter::LanguageServer { name: None }].into(),
507                )));
508                file.defaults.prettier = Some(PrettierSettings {
509                    allowed: true,
510                    ..PrettierSettings::default()
511                });
512            });
513        });
514    });
515    let fake_language_server = fake_language_servers.next().await.unwrap();
516    fake_language_server.set_request_handler::<lsp::request::Formatting, _, _>(|_, _| async move {
517        panic!(
518            "Unexpected: prettier should be preferred since it's enabled and language supports it"
519        )
520    });
521
522    project_b
523        .update(cx_b, |project, cx| {
524            project.format(
525                HashSet::from_iter([buffer_b.clone()]),
526                LspFormatTarget::Buffers,
527                true,
528                FormatTrigger::Save,
529                cx,
530            )
531        })
532        .await
533        .unwrap();
534
535    executor.run_until_parked();
536    assert_eq!(
537        buffer_b.read_with(cx_b, |buffer, _| buffer.text()),
538        buffer_text.to_string() + "\n" + prettier_format_suffix,
539        "Prettier formatting was not applied to client buffer after client's request"
540    );
541
542    // User A opens and formats the same buffer too
543    let buffer_a = project_a
544        .update(cx_a, |p, cx| p.open_buffer((worktree_id, "a.ts"), cx))
545        .await
546        .expect("user A opens buffer for formatting");
547
548    cx_a.update(|cx| {
549        SettingsStore::update_global(cx, |store, cx| {
550            store.update_user_settings::<AllLanguageSettings>(cx, |file| {
551                file.defaults.formatter = Some(SelectedFormatter::Auto);
552                file.defaults.prettier = Some(PrettierSettings {
553                    allowed: true,
554                    ..PrettierSettings::default()
555                });
556            });
557        });
558    });
559    project_a
560        .update(cx_a, |project, cx| {
561            project.format(
562                HashSet::from_iter([buffer_a.clone()]),
563                LspFormatTarget::Buffers,
564                true,
565                FormatTrigger::Manual,
566                cx,
567            )
568        })
569        .await
570        .unwrap();
571
572    executor.run_until_parked();
573    assert_eq!(
574        buffer_b.read_with(cx_b, |buffer, _| buffer.text()),
575        buffer_text.to_string() + "\n" + prettier_format_suffix + "\n" + prettier_format_suffix,
576        "Prettier formatting was not applied to client buffer after host's request"
577    );
578}