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