remote_editing_tests.rs

  1use crate::headless_project::HeadlessProject;
  2use client::{Client, UserStore};
  3use clock::FakeSystemClock;
  4use fs::{FakeFs, Fs};
  5use gpui::{Context, Model, TestAppContext};
  6use http_client::FakeHttpClient;
  7use language::{
  8    language_settings::{all_language_settings, AllLanguageSettings},
  9    Buffer, FakeLspAdapter, LanguageConfig, LanguageMatcher, LanguageRegistry, LanguageServerName,
 10    LineEnding,
 11};
 12use lsp::{CompletionContext, CompletionResponse, CompletionTriggerKind};
 13use node_runtime::NodeRuntime;
 14use project::{
 15    search::{SearchQuery, SearchResult},
 16    Project, ProjectPath,
 17};
 18use remote::SshSession;
 19use serde_json::json;
 20use settings::{Settings, SettingsLocation, SettingsStore};
 21use smol::stream::StreamExt;
 22use std::{
 23    path::{Path, PathBuf},
 24    sync::Arc,
 25};
 26
 27#[gpui::test]
 28async fn test_basic_remote_editing(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
 29    let (project, _headless, fs) = init_test(cx, server_cx).await;
 30    let (worktree, _) = project
 31        .update(cx, |project, cx| {
 32            project.find_or_create_worktree("/code/project1", true, cx)
 33        })
 34        .await
 35        .unwrap();
 36
 37    // The client sees the worktree's contents.
 38    cx.executor().run_until_parked();
 39    let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
 40    worktree.update(cx, |worktree, _cx| {
 41        assert_eq!(
 42            worktree.paths().map(Arc::as_ref).collect::<Vec<_>>(),
 43            vec![
 44                Path::new("README.md"),
 45                Path::new("src"),
 46                Path::new("src/lib.rs"),
 47            ]
 48        );
 49    });
 50
 51    // The user opens a buffer in the remote worktree. The buffer's
 52    // contents are loaded from the remote filesystem.
 53    let buffer = project
 54        .update(cx, |project, cx| {
 55            project.open_buffer((worktree_id, Path::new("src/lib.rs")), cx)
 56        })
 57        .await
 58        .unwrap();
 59    buffer.update(cx, |buffer, cx| {
 60        assert_eq!(buffer.text(), "fn one() -> usize { 1 }");
 61        assert_eq!(
 62            buffer.diff_base().unwrap().to_string(),
 63            "fn one() -> usize { 0 }"
 64        );
 65        let ix = buffer.text().find('1').unwrap();
 66        buffer.edit([(ix..ix + 1, "100")], None, cx);
 67    });
 68
 69    // The user saves the buffer. The new contents are written to the
 70    // remote filesystem.
 71    project
 72        .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
 73        .await
 74        .unwrap();
 75    assert_eq!(
 76        fs.load("/code/project1/src/lib.rs".as_ref()).await.unwrap(),
 77        "fn one() -> usize { 100 }"
 78    );
 79
 80    // A new file is created in the remote filesystem. The user
 81    // sees the new file.
 82    fs.save(
 83        "/code/project1/src/main.rs".as_ref(),
 84        &"fn main() {}".into(),
 85        Default::default(),
 86    )
 87    .await
 88    .unwrap();
 89    cx.executor().run_until_parked();
 90    worktree.update(cx, |worktree, _cx| {
 91        assert_eq!(
 92            worktree.paths().map(Arc::as_ref).collect::<Vec<_>>(),
 93            vec![
 94                Path::new("README.md"),
 95                Path::new("src"),
 96                Path::new("src/lib.rs"),
 97                Path::new("src/main.rs"),
 98            ]
 99        );
100    });
101
102    // A file that is currently open in a buffer is renamed.
103    fs.rename(
104        "/code/project1/src/lib.rs".as_ref(),
105        "/code/project1/src/lib2.rs".as_ref(),
106        Default::default(),
107    )
108    .await
109    .unwrap();
110    cx.executor().run_until_parked();
111    buffer.update(cx, |buffer, _| {
112        assert_eq!(&**buffer.file().unwrap().path(), Path::new("src/lib2.rs"));
113    });
114
115    fs.set_index_for_repo(
116        Path::new("/code/project1/.git"),
117        &[(Path::new("src/lib2.rs"), "fn one() -> usize { 100 }".into())],
118    );
119    cx.executor().run_until_parked();
120    buffer.update(cx, |buffer, _| {
121        assert_eq!(
122            buffer.diff_base().unwrap().to_string(),
123            "fn one() -> usize { 100 }"
124        );
125    });
126}
127
128#[gpui::test]
129async fn test_remote_project_search(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
130    let (project, headless, _) = init_test(cx, server_cx).await;
131
132    project
133        .update(cx, |project, cx| {
134            project.find_or_create_worktree("/code/project1", true, cx)
135        })
136        .await
137        .unwrap();
138
139    cx.run_until_parked();
140
141    async fn do_search(project: &Model<Project>, mut cx: TestAppContext) -> Model<Buffer> {
142        let mut receiver = project.update(&mut cx, |project, cx| {
143            project.search(
144                SearchQuery::text(
145                    "project",
146                    false,
147                    true,
148                    false,
149                    Default::default(),
150                    Default::default(),
151                    None,
152                )
153                .unwrap(),
154                cx,
155            )
156        });
157
158        let first_response = receiver.next().await.unwrap();
159        let SearchResult::Buffer { buffer, .. } = first_response else {
160            panic!("incorrect result");
161        };
162        buffer.update(&mut cx, |buffer, cx| {
163            assert_eq!(
164                buffer.file().unwrap().full_path(cx).to_string_lossy(),
165                "project1/README.md"
166            )
167        });
168
169        assert!(receiver.next().await.is_none());
170        buffer
171    }
172
173    let buffer = do_search(&project, cx.clone()).await;
174
175    // test that the headless server is tracking which buffers we have open correctly.
176    cx.run_until_parked();
177    headless.update(server_cx, |headless, cx| {
178        assert!(!headless.buffer_store.read(cx).shared_buffers().is_empty())
179    });
180    do_search(&project, cx.clone()).await;
181
182    cx.update(|_| {
183        drop(buffer);
184    });
185    cx.run_until_parked();
186    headless.update(server_cx, |headless, cx| {
187        assert!(headless.buffer_store.read(cx).shared_buffers().is_empty())
188    });
189
190    do_search(&project, cx.clone()).await;
191}
192
193#[gpui::test]
194async fn test_remote_settings(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
195    let (project, headless, fs) = init_test(cx, server_cx).await;
196
197    cx.update_global(|settings_store: &mut SettingsStore, cx| {
198        settings_store.set_user_settings(
199            r#"{"languages":{"Rust":{"language_servers":["custom-rust-analyzer"]}}}"#,
200            cx,
201        )
202    })
203    .unwrap();
204
205    cx.run_until_parked();
206
207    server_cx.read(|cx| {
208        assert_eq!(
209            AllLanguageSettings::get_global(cx)
210                .language(Some(&"Rust".into()))
211                .language_servers,
212            ["custom-rust-analyzer".to_string()]
213        )
214    });
215
216    fs.insert_tree(
217        "/code/project1/.zed",
218        json!({
219            "settings.json": r#"
220                  {
221                    "languages": {"Rust":{"language_servers":["override-rust-analyzer"]}},
222                    "lsp": {
223                      "override-rust-analyzer": {
224                        "binary": {
225                          "path": "~/.cargo/bin/rust-analyzer"
226                        }
227                      }
228                    }
229                  }"#
230        }),
231    )
232    .await;
233
234    let worktree_id = project
235        .update(cx, |project, cx| {
236            project.find_or_create_worktree("/code/project1", true, cx)
237        })
238        .await
239        .unwrap()
240        .0
241        .read_with(cx, |worktree, _| worktree.id());
242
243    let buffer = project
244        .update(cx, |project, cx| {
245            project.open_buffer((worktree_id, Path::new("src/lib.rs")), cx)
246        })
247        .await
248        .unwrap();
249    cx.run_until_parked();
250
251    server_cx.read(|cx| {
252        let worktree_id = headless
253            .read(cx)
254            .worktree_store
255            .read(cx)
256            .worktrees()
257            .next()
258            .unwrap()
259            .read(cx)
260            .id();
261        assert_eq!(
262            AllLanguageSettings::get(
263                Some(SettingsLocation {
264                    worktree_id,
265                    path: Path::new("src/lib.rs")
266                }),
267                cx
268            )
269            .language(Some(&"Rust".into()))
270            .language_servers,
271            ["override-rust-analyzer".to_string()]
272        )
273    });
274
275    cx.read(|cx| {
276        let file = buffer.read(cx).file();
277        assert_eq!(
278            all_language_settings(file, cx)
279                .language(Some(&"Rust".into()))
280                .language_servers,
281            ["override-rust-analyzer".to_string()]
282        )
283    });
284}
285
286#[gpui::test]
287async fn test_remote_lsp(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
288    let (project, headless, fs) = init_test(cx, server_cx).await;
289
290    fs.insert_tree(
291        "/code/project1/.zed",
292        json!({
293            "settings.json": r#"
294          {
295            "languages": {"Rust":{"language_servers":["rust-analyzer"]}},
296            "lsp": {
297              "rust-analyzer": {
298                "binary": {
299                  "path": "~/.cargo/bin/rust-analyzer"
300                }
301              }
302            }
303          }"#
304        }),
305    )
306    .await;
307
308    cx.update_model(&project, |project, _| {
309        project.languages().register_test_language(LanguageConfig {
310            name: "Rust".into(),
311            matcher: LanguageMatcher {
312                path_suffixes: vec!["rs".into()],
313                ..Default::default()
314            },
315            ..Default::default()
316        });
317        project.languages().register_fake_lsp_adapter(
318            "Rust",
319            FakeLspAdapter {
320                name: "rust-analyzer",
321                ..Default::default()
322            },
323        )
324    });
325
326    let mut fake_lsp = server_cx.update(|cx| {
327        headless.read(cx).languages.register_fake_language_server(
328            LanguageServerName("rust-analyzer".into()),
329            Default::default(),
330            None,
331        )
332    });
333
334    cx.run_until_parked();
335
336    let worktree_id = project
337        .update(cx, |project, cx| {
338            project.find_or_create_worktree("/code/project1", true, cx)
339        })
340        .await
341        .unwrap()
342        .0
343        .read_with(cx, |worktree, _| worktree.id());
344
345    // Wait for the settings to synchronize
346    cx.run_until_parked();
347
348    let buffer = project
349        .update(cx, |project, cx| {
350            project.open_buffer((worktree_id, Path::new("src/lib.rs")), cx)
351        })
352        .await
353        .unwrap();
354    cx.run_until_parked();
355
356    let fake_lsp = fake_lsp.next().await.unwrap();
357
358    cx.read(|cx| {
359        let file = buffer.read(cx).file();
360        assert_eq!(
361            all_language_settings(file, cx)
362                .language(Some(&"Rust".into()))
363                .language_servers,
364            ["rust-analyzer".to_string()]
365        )
366    });
367
368    let buffer_id = cx.read(|cx| {
369        let buffer = buffer.read(cx);
370        assert_eq!(buffer.language().unwrap().name(), "Rust".into());
371        buffer.remote_id()
372    });
373
374    server_cx.read(|cx| {
375        let buffer = headless
376            .read(cx)
377            .buffer_store
378            .read(cx)
379            .get(buffer_id)
380            .unwrap();
381
382        assert_eq!(buffer.read(cx).language().unwrap().name(), "Rust".into());
383    });
384
385    server_cx.read(|cx| {
386        let lsp_store = headless.read(cx).lsp_store.read(cx);
387        assert_eq!(lsp_store.as_local().unwrap().language_servers.len(), 1);
388    });
389
390    fake_lsp.handle_request::<lsp::request::Completion, _, _>(|_, _| async move {
391        Ok(Some(CompletionResponse::Array(vec![lsp::CompletionItem {
392            label: "boop".to_string(),
393            ..Default::default()
394        }])))
395    });
396
397    let result = project
398        .update(cx, |project, cx| {
399            project.completions(
400                &buffer,
401                0,
402                CompletionContext {
403                    trigger_kind: CompletionTriggerKind::INVOKED,
404                    trigger_character: None,
405                },
406                cx,
407            )
408        })
409        .await
410        .unwrap();
411
412    assert_eq!(
413        result.into_iter().map(|c| c.label.text).collect::<Vec<_>>(),
414        vec!["boop".to_string()]
415    );
416
417    fake_lsp.handle_request::<lsp::request::Rename, _, _>(|_, _| async move {
418        Ok(Some(lsp::WorkspaceEdit {
419            changes: Some(
420                [(
421                    lsp::Url::from_file_path("/code/project1/src/lib.rs").unwrap(),
422                    vec![lsp::TextEdit::new(
423                        lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(0, 6)),
424                        "two".to_string(),
425                    )],
426                )]
427                .into_iter()
428                .collect(),
429            ),
430            ..Default::default()
431        }))
432    });
433
434    project
435        .update(cx, |project, cx| {
436            project.perform_rename(buffer.clone(), 3, "two".to_string(), true, cx)
437        })
438        .await
439        .unwrap();
440
441    cx.run_until_parked();
442    buffer.update(cx, |buffer, _| {
443        assert_eq!(buffer.text(), "fn two() -> usize { 1 }")
444    })
445}
446
447#[gpui::test]
448async fn test_remote_reload(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
449    let (project, _headless, fs) = init_test(cx, server_cx).await;
450    let (worktree, _) = project
451        .update(cx, |project, cx| {
452            project.find_or_create_worktree("/code/project1", true, cx)
453        })
454        .await
455        .unwrap();
456
457    let worktree_id = cx.update(|cx| worktree.read(cx).id());
458
459    let buffer = project
460        .update(cx, |project, cx| {
461            project.open_buffer((worktree_id, Path::new("src/lib.rs")), cx)
462        })
463        .await
464        .unwrap();
465    buffer.update(cx, |buffer, cx| {
466        buffer.edit([(0..0, "a")], None, cx);
467    });
468
469    fs.save(
470        &PathBuf::from("/code/project1/src/lib.rs"),
471        &("bloop".to_string().into()),
472        LineEnding::Unix,
473    )
474    .await
475    .unwrap();
476
477    cx.run_until_parked();
478    cx.update(|cx| {
479        assert!(buffer.read(cx).has_conflict());
480    });
481
482    project
483        .update(cx, |project, cx| {
484            project.reload_buffers([buffer.clone()].into_iter().collect(), false, cx)
485        })
486        .await
487        .unwrap();
488    cx.run_until_parked();
489
490    cx.update(|cx| {
491        assert!(!buffer.read(cx).has_conflict());
492    });
493}
494
495#[gpui::test]
496async fn test_remote_resolve_file_path(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
497    let (project, _headless, _fs) = init_test(cx, server_cx).await;
498    let (worktree, _) = project
499        .update(cx, |project, cx| {
500            project.find_or_create_worktree("/code/project1", true, cx)
501        })
502        .await
503        .unwrap();
504
505    let worktree_id = cx.update(|cx| worktree.read(cx).id());
506
507    let buffer = project
508        .update(cx, |project, cx| {
509            project.open_buffer((worktree_id, Path::new("src/lib.rs")), cx)
510        })
511        .await
512        .unwrap();
513
514    let path = project
515        .update(cx, |project, cx| {
516            project.resolve_existing_file_path("/code/project1/README.md", &buffer, cx)
517        })
518        .await
519        .unwrap();
520    assert_eq!(
521        path.abs_path().unwrap().to_string_lossy(),
522        "/code/project1/README.md"
523    );
524
525    let path = project
526        .update(cx, |project, cx| {
527            project.resolve_existing_file_path("../README.md", &buffer, cx)
528        })
529        .await
530        .unwrap();
531
532    assert_eq!(
533        path.project_path().unwrap().clone(),
534        ProjectPath::from((worktree_id, "README.md"))
535    );
536}
537
538#[gpui::test(iterations = 10)]
539async fn test_canceling_buffer_opening(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
540    let (project, _headless, _fs) = init_test(cx, server_cx).await;
541    let (worktree, _) = project
542        .update(cx, |project, cx| {
543            project.find_or_create_worktree("/code/project1", true, cx)
544        })
545        .await
546        .unwrap();
547    let worktree_id = worktree.read_with(cx, |tree, _| tree.id());
548
549    // Open a buffer on the client but cancel after a random amount of time.
550    let buffer = project.update(cx, |p, cx| p.open_buffer((worktree_id, "src/lib.rs"), cx));
551    cx.executor().simulate_random_delay().await;
552    drop(buffer);
553
554    // Try opening the same buffer again as the client, and ensure we can
555    // still do it despite the cancellation above.
556    let buffer = project
557        .update(cx, |p, cx| p.open_buffer((worktree_id, "src/lib.rs"), cx))
558        .await
559        .unwrap();
560
561    buffer.read_with(cx, |buf, _| {
562        assert_eq!(buf.text(), "fn one() -> usize { 1 }")
563    });
564}
565
566fn init_logger() {
567    if std::env::var("RUST_LOG").is_ok() {
568        env_logger::try_init().ok();
569    }
570}
571
572async fn init_test(
573    cx: &mut TestAppContext,
574    server_cx: &mut TestAppContext,
575) -> (Model<Project>, Model<HeadlessProject>, Arc<FakeFs>) {
576    let (client_ssh, server_ssh) = SshSession::fake(cx, server_cx);
577    init_logger();
578
579    let fs = FakeFs::new(server_cx.executor());
580    fs.insert_tree(
581        "/code",
582        json!({
583            "project1": {
584                ".git": {},
585                "README.md": "# project 1",
586                "src": {
587                    "lib.rs": "fn one() -> usize { 1 }"
588                }
589            },
590            "project2": {
591                "README.md": "# project 2",
592            },
593        }),
594    )
595    .await;
596    fs.set_index_for_repo(
597        Path::new("/code/project1/.git"),
598        &[(Path::new("src/lib.rs"), "fn one() -> usize { 0 }".into())],
599    );
600
601    server_cx.update(HeadlessProject::init);
602    let headless = server_cx.new_model(|cx| HeadlessProject::new(server_ssh, fs.clone(), cx));
603    let project = build_project(client_ssh, cx);
604
605    project
606        .update(cx, {
607            let headless = headless.clone();
608            |_, cx| cx.on_release(|_, _| drop(headless))
609        })
610        .detach();
611    (project, headless, fs)
612}
613
614fn build_project(ssh: Arc<SshSession>, cx: &mut TestAppContext) -> Model<Project> {
615    cx.update(|cx| {
616        let settings_store = SettingsStore::test(cx);
617        cx.set_global(settings_store);
618    });
619
620    let client = cx.update(|cx| {
621        Client::new(
622            Arc::new(FakeSystemClock::default()),
623            FakeHttpClient::with_404_response(),
624            cx,
625        )
626    });
627
628    let node = NodeRuntime::unavailable();
629    let user_store = cx.new_model(|cx| UserStore::new(client.clone(), cx));
630    let languages = Arc::new(LanguageRegistry::test(cx.executor()));
631    let fs = FakeFs::new(cx.executor());
632    cx.update(|cx| {
633        Project::init(&client, cx);
634        language::init(cx);
635    });
636
637    cx.update(|cx| Project::ssh(ssh, client, node, user_store, languages, fs, cx))
638}