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