1use crate::headless_project::HeadlessProject;
2use client::{Client, UserStore};
3use clock::FakeSystemClock;
4use fs::{FakeFs, Fs};
5use gpui::{Context, Model, TestAppContext};
6use http_client::{BlockedHttpClient, FakeHttpClient};
7use language::{
8 language_settings::{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::SshRemoteClient;
19use serde_json::json;
20use settings::{initial_server_settings_content, 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":["from-local-settings"]}}}"#,
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(None, Some(&"Rust".into()), cx)
212 .language_servers,
213 ["from-local-settings".to_string()]
214 )
215 });
216
217 server_cx
218 .update_global(|settings_store: &mut SettingsStore, cx| {
219 settings_store.set_server_settings(
220 r#"{"languages":{"Rust":{"language_servers":["from-server-settings"]}}}"#,
221 cx,
222 )
223 })
224 .unwrap();
225
226 cx.run_until_parked();
227
228 server_cx.read(|cx| {
229 assert_eq!(
230 AllLanguageSettings::get_global(cx)
231 .language(None, Some(&"Rust".into()), cx)
232 .language_servers,
233 ["from-server-settings".to_string()]
234 )
235 });
236
237 fs.insert_tree(
238 "/code/project1/.zed",
239 json!({
240 "settings.json": r#"
241 {
242 "languages": {"Rust":{"language_servers":["override-rust-analyzer"]}},
243 "lsp": {
244 "override-rust-analyzer": {
245 "binary": {
246 "path": "~/.cargo/bin/rust-analyzer"
247 }
248 }
249 }
250 }"#
251 }),
252 )
253 .await;
254
255 let worktree_id = project
256 .update(cx, |project, cx| {
257 project.find_or_create_worktree("/code/project1", true, cx)
258 })
259 .await
260 .unwrap()
261 .0
262 .read_with(cx, |worktree, _| worktree.id());
263
264 let buffer = project
265 .update(cx, |project, cx| {
266 project.open_buffer((worktree_id, Path::new("src/lib.rs")), cx)
267 })
268 .await
269 .unwrap();
270 cx.run_until_parked();
271
272 server_cx.read(|cx| {
273 let worktree_id = headless
274 .read(cx)
275 .worktree_store
276 .read(cx)
277 .worktrees()
278 .next()
279 .unwrap()
280 .read(cx)
281 .id();
282 assert_eq!(
283 AllLanguageSettings::get(
284 Some(SettingsLocation {
285 worktree_id,
286 path: Path::new("src/lib.rs")
287 }),
288 cx
289 )
290 .language(None, Some(&"Rust".into()), cx)
291 .language_servers,
292 ["override-rust-analyzer".to_string()]
293 )
294 });
295
296 cx.read(|cx| {
297 let file = buffer.read(cx).file();
298 assert_eq!(
299 language_settings(Some("Rust".into()), file, cx).language_servers,
300 ["override-rust-analyzer".to_string()]
301 )
302 });
303}
304
305#[gpui::test]
306async fn test_remote_lsp(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
307 let (project, headless, fs) = init_test(cx, server_cx).await;
308
309 fs.insert_tree(
310 "/code/project1/.zed",
311 json!({
312 "settings.json": r#"
313 {
314 "languages": {"Rust":{"language_servers":["rust-analyzer"]}},
315 "lsp": {
316 "rust-analyzer": {
317 "binary": {
318 "path": "~/.cargo/bin/rust-analyzer"
319 }
320 }
321 }
322 }"#
323 }),
324 )
325 .await;
326
327 cx.update_model(&project, |project, _| {
328 project.languages().register_test_language(LanguageConfig {
329 name: "Rust".into(),
330 matcher: LanguageMatcher {
331 path_suffixes: vec!["rs".into()],
332 ..Default::default()
333 },
334 ..Default::default()
335 });
336 project.languages().register_fake_lsp_adapter(
337 "Rust",
338 FakeLspAdapter {
339 name: "rust-analyzer",
340 ..Default::default()
341 },
342 )
343 });
344
345 let mut fake_lsp = server_cx.update(|cx| {
346 headless.read(cx).languages.register_fake_language_server(
347 LanguageServerName("rust-analyzer".into()),
348 Default::default(),
349 None,
350 )
351 });
352
353 cx.run_until_parked();
354
355 let worktree_id = project
356 .update(cx, |project, cx| {
357 project.find_or_create_worktree("/code/project1", true, cx)
358 })
359 .await
360 .unwrap()
361 .0
362 .read_with(cx, |worktree, _| worktree.id());
363
364 // Wait for the settings to synchronize
365 cx.run_until_parked();
366
367 let buffer = project
368 .update(cx, |project, cx| {
369 project.open_buffer((worktree_id, Path::new("src/lib.rs")), cx)
370 })
371 .await
372 .unwrap();
373 cx.run_until_parked();
374
375 let fake_lsp = fake_lsp.next().await.unwrap();
376
377 cx.read(|cx| {
378 let file = buffer.read(cx).file();
379 assert_eq!(
380 language_settings(Some("Rust".into()), file, cx).language_servers,
381 ["rust-analyzer".to_string()]
382 )
383 });
384
385 let buffer_id = cx.read(|cx| {
386 let buffer = buffer.read(cx);
387 assert_eq!(buffer.language().unwrap().name(), "Rust".into());
388 buffer.remote_id()
389 });
390
391 server_cx.read(|cx| {
392 let buffer = headless
393 .read(cx)
394 .buffer_store
395 .read(cx)
396 .get(buffer_id)
397 .unwrap();
398
399 assert_eq!(buffer.read(cx).language().unwrap().name(), "Rust".into());
400 });
401
402 server_cx.read(|cx| {
403 let lsp_store = headless.read(cx).lsp_store.read(cx);
404 assert_eq!(lsp_store.as_local().unwrap().language_servers.len(), 1);
405 });
406
407 fake_lsp.handle_request::<lsp::request::Completion, _, _>(|_, _| async move {
408 Ok(Some(CompletionResponse::Array(vec![lsp::CompletionItem {
409 label: "boop".to_string(),
410 ..Default::default()
411 }])))
412 });
413
414 let result = project
415 .update(cx, |project, cx| {
416 project.completions(
417 &buffer,
418 0,
419 CompletionContext {
420 trigger_kind: CompletionTriggerKind::INVOKED,
421 trigger_character: None,
422 },
423 cx,
424 )
425 })
426 .await
427 .unwrap();
428
429 assert_eq!(
430 result.into_iter().map(|c| c.label.text).collect::<Vec<_>>(),
431 vec!["boop".to_string()]
432 );
433
434 fake_lsp.handle_request::<lsp::request::Rename, _, _>(|_, _| async move {
435 Ok(Some(lsp::WorkspaceEdit {
436 changes: Some(
437 [(
438 lsp::Url::from_file_path("/code/project1/src/lib.rs").unwrap(),
439 vec![lsp::TextEdit::new(
440 lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(0, 6)),
441 "two".to_string(),
442 )],
443 )]
444 .into_iter()
445 .collect(),
446 ),
447 ..Default::default()
448 }))
449 });
450
451 project
452 .update(cx, |project, cx| {
453 project.perform_rename(buffer.clone(), 3, "two".to_string(), cx)
454 })
455 .await
456 .unwrap();
457
458 cx.run_until_parked();
459 buffer.update(cx, |buffer, _| {
460 assert_eq!(buffer.text(), "fn two() -> usize { 1 }")
461 })
462}
463
464#[gpui::test]
465async fn test_remote_reload(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
466 let (project, _headless, fs) = init_test(cx, server_cx).await;
467 let (worktree, _) = project
468 .update(cx, |project, cx| {
469 project.find_or_create_worktree("/code/project1", true, cx)
470 })
471 .await
472 .unwrap();
473
474 let worktree_id = cx.update(|cx| worktree.read(cx).id());
475
476 let buffer = project
477 .update(cx, |project, cx| {
478 project.open_buffer((worktree_id, Path::new("src/lib.rs")), cx)
479 })
480 .await
481 .unwrap();
482 buffer.update(cx, |buffer, cx| {
483 buffer.edit([(0..0, "a")], None, cx);
484 });
485
486 fs.save(
487 &PathBuf::from("/code/project1/src/lib.rs"),
488 &("bloop".to_string().into()),
489 LineEnding::Unix,
490 )
491 .await
492 .unwrap();
493
494 cx.run_until_parked();
495 cx.update(|cx| {
496 assert!(buffer.read(cx).has_conflict());
497 });
498
499 project
500 .update(cx, |project, cx| {
501 project.reload_buffers([buffer.clone()].into_iter().collect(), false, cx)
502 })
503 .await
504 .unwrap();
505 cx.run_until_parked();
506
507 cx.update(|cx| {
508 assert!(!buffer.read(cx).has_conflict());
509 });
510}
511
512#[gpui::test]
513async fn test_remote_resolve_file_path(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
514 let (project, _headless, _fs) = init_test(cx, server_cx).await;
515 let (worktree, _) = project
516 .update(cx, |project, cx| {
517 project.find_or_create_worktree("/code/project1", true, cx)
518 })
519 .await
520 .unwrap();
521
522 let worktree_id = cx.update(|cx| worktree.read(cx).id());
523
524 let buffer = project
525 .update(cx, |project, cx| {
526 project.open_buffer((worktree_id, Path::new("src/lib.rs")), cx)
527 })
528 .await
529 .unwrap();
530
531 let path = project
532 .update(cx, |project, cx| {
533 project.resolve_existing_file_path("/code/project1/README.md", &buffer, cx)
534 })
535 .await
536 .unwrap();
537 assert_eq!(
538 path.abs_path().unwrap().to_string_lossy(),
539 "/code/project1/README.md"
540 );
541
542 let path = project
543 .update(cx, |project, cx| {
544 project.resolve_existing_file_path("../README.md", &buffer, cx)
545 })
546 .await
547 .unwrap();
548
549 assert_eq!(
550 path.project_path().unwrap().clone(),
551 ProjectPath::from((worktree_id, "README.md"))
552 );
553}
554
555#[gpui::test(iterations = 10)]
556async fn test_canceling_buffer_opening(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
557 let (project, _headless, _fs) = init_test(cx, server_cx).await;
558 let (worktree, _) = project
559 .update(cx, |project, cx| {
560 project.find_or_create_worktree("/code/project1", true, cx)
561 })
562 .await
563 .unwrap();
564 let worktree_id = worktree.read_with(cx, |tree, _| tree.id());
565
566 // Open a buffer on the client but cancel after a random amount of time.
567 let buffer = project.update(cx, |p, cx| p.open_buffer((worktree_id, "src/lib.rs"), cx));
568 cx.executor().simulate_random_delay().await;
569 drop(buffer);
570
571 // Try opening the same buffer again as the client, and ensure we can
572 // still do it despite the cancellation above.
573 let buffer = project
574 .update(cx, |p, cx| p.open_buffer((worktree_id, "src/lib.rs"), cx))
575 .await
576 .unwrap();
577
578 buffer.read_with(cx, |buf, _| {
579 assert_eq!(buf.text(), "fn one() -> usize { 1 }")
580 });
581}
582
583#[gpui::test]
584async fn test_adding_then_removing_then_adding_worktrees(
585 cx: &mut TestAppContext,
586 server_cx: &mut TestAppContext,
587) {
588 let (project, _headless, _fs) = init_test(cx, server_cx).await;
589 let (_worktree, _) = project
590 .update(cx, |project, cx| {
591 project.find_or_create_worktree("/code/project1", true, cx)
592 })
593 .await
594 .unwrap();
595
596 let (worktree_2, _) = project
597 .update(cx, |project, cx| {
598 project.find_or_create_worktree("/code/project2", true, cx)
599 })
600 .await
601 .unwrap();
602 let worktree_id_2 = worktree_2.read_with(cx, |tree, _| tree.id());
603
604 project.update(cx, |project, cx| project.remove_worktree(worktree_id_2, cx));
605
606 let (worktree_2, _) = project
607 .update(cx, |project, cx| {
608 project.find_or_create_worktree("/code/project2", true, cx)
609 })
610 .await
611 .unwrap();
612
613 cx.run_until_parked();
614 worktree_2.update(cx, |worktree, _cx| {
615 assert!(worktree.is_visible());
616 let entries = worktree.entries(true, 0).collect::<Vec<_>>();
617 assert_eq!(entries.len(), 2);
618 assert_eq!(
619 entries[1].path.to_string_lossy().to_string(),
620 "README.md".to_string()
621 )
622 })
623}
624
625#[gpui::test]
626async fn test_open_server_settings(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
627 let (project, _headless, _fs) = init_test(cx, server_cx).await;
628 let buffer = project.update(cx, |project, cx| project.open_server_settings(cx));
629 cx.executor().run_until_parked();
630 let buffer = buffer.await.unwrap();
631
632 cx.update(|cx| {
633 assert_eq!(
634 buffer.read(cx).text(),
635 initial_server_settings_content().to_string()
636 )
637 })
638}
639
640#[gpui::test(iterations = 20)]
641async fn test_reconnect(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
642 let (project, _headless, fs) = init_test(cx, server_cx).await;
643
644 let (worktree, _) = project
645 .update(cx, |project, cx| {
646 project.find_or_create_worktree("/code/project1", true, cx)
647 })
648 .await
649 .unwrap();
650
651 let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
652 let buffer = project
653 .update(cx, |project, cx| {
654 project.open_buffer((worktree_id, Path::new("src/lib.rs")), cx)
655 })
656 .await
657 .unwrap();
658
659 buffer.update(cx, |buffer, cx| {
660 assert_eq!(buffer.text(), "fn one() -> usize { 1 }");
661 let ix = buffer.text().find('1').unwrap();
662 buffer.edit([(ix..ix + 1, "100")], None, cx);
663 });
664
665 let client = cx.read(|cx| project.read(cx).ssh_client().unwrap());
666 client
667 .update(cx, |client, cx| client.simulate_disconnect(cx))
668 .detach();
669
670 project
671 .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx))
672 .await
673 .unwrap();
674
675 assert_eq!(
676 fs.load("/code/project1/src/lib.rs".as_ref()).await.unwrap(),
677 "fn one() -> usize { 100 }"
678 );
679}
680
681fn init_logger() {
682 if std::env::var("RUST_LOG").is_ok() {
683 env_logger::try_init().ok();
684 }
685}
686
687async fn init_test(
688 cx: &mut TestAppContext,
689 server_cx: &mut TestAppContext,
690) -> (Model<Project>, Model<HeadlessProject>, Arc<FakeFs>) {
691 init_logger();
692
693 let (forwarder, ssh_server_client) = SshRemoteClient::fake_server(cx, server_cx);
694 let fs = FakeFs::new(server_cx.executor());
695 fs.insert_tree(
696 "/code",
697 json!({
698 "project1": {
699 ".git": {},
700 "README.md": "# project 1",
701 "src": {
702 "lib.rs": "fn one() -> usize { 1 }"
703 }
704 },
705 "project2": {
706 "README.md": "# project 2",
707 },
708 }),
709 )
710 .await;
711 fs.set_index_for_repo(
712 Path::new("/code/project1/.git"),
713 &[(Path::new("src/lib.rs"), "fn one() -> usize { 0 }".into())],
714 );
715
716 server_cx.update(HeadlessProject::init);
717 let http_client = Arc::new(BlockedHttpClient);
718 let node_runtime = NodeRuntime::unavailable();
719 let languages = Arc::new(LanguageRegistry::new(cx.executor()));
720 let headless = server_cx.new_model(|cx| {
721 client::init_settings(cx);
722
723 HeadlessProject::new(
724 crate::HeadlessAppState {
725 session: ssh_server_client,
726 fs: fs.clone(),
727 http_client,
728 node_runtime,
729 languages,
730 },
731 cx,
732 )
733 });
734
735 let ssh = SshRemoteClient::fake_client(forwarder, cx).await;
736 let project = build_project(ssh, cx);
737 project
738 .update(cx, {
739 let headless = headless.clone();
740 |_, cx| cx.on_release(|_, _| drop(headless))
741 })
742 .detach();
743 (project, headless, fs)
744}
745
746fn build_project(ssh: Model<SshRemoteClient>, cx: &mut TestAppContext) -> Model<Project> {
747 cx.update(|cx| {
748 let settings_store = SettingsStore::test(cx);
749 cx.set_global(settings_store);
750 });
751
752 let client = cx.update(|cx| {
753 Client::new(
754 Arc::new(FakeSystemClock::default()),
755 FakeHttpClient::with_404_response(),
756 cx,
757 )
758 });
759
760 let node = NodeRuntime::unavailable();
761 let user_store = cx.new_model(|cx| UserStore::new(client.clone(), cx));
762 let languages = Arc::new(LanguageRegistry::test(cx.executor()));
763 let fs = FakeFs::new(cx.executor());
764 cx.update(|cx| {
765 Project::init(&client, cx);
766 language::init(cx);
767 });
768
769 cx.update(|cx| Project::ssh(ssh, client, node, user_store, languages, fs, cx))
770}