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::SshRemoteClient;
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(), 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
567#[gpui::test]
568async fn test_adding_then_removing_then_adding_worktrees(
569 cx: &mut TestAppContext,
570 server_cx: &mut TestAppContext,
571) {
572 let (project, _headless, _fs) = init_test(cx, server_cx).await;
573 let (_worktree, _) = project
574 .update(cx, |project, cx| {
575 project.find_or_create_worktree("/code/project1", true, cx)
576 })
577 .await
578 .unwrap();
579
580 let (worktree_2, _) = project
581 .update(cx, |project, cx| {
582 project.find_or_create_worktree("/code/project2", true, cx)
583 })
584 .await
585 .unwrap();
586 let worktree_id_2 = worktree_2.read_with(cx, |tree, _| tree.id());
587
588 project.update(cx, |project, cx| project.remove_worktree(worktree_id_2, cx));
589
590 let (worktree_2, _) = project
591 .update(cx, |project, cx| {
592 project.find_or_create_worktree("/code/project2", true, cx)
593 })
594 .await
595 .unwrap();
596
597 cx.run_until_parked();
598 worktree_2.update(cx, |worktree, _cx| {
599 assert!(worktree.is_visible());
600 let entries = worktree.entries(true, 0).collect::<Vec<_>>();
601 assert_eq!(entries.len(), 2);
602 assert_eq!(
603 entries[1].path.to_string_lossy().to_string(),
604 "README.md".to_string()
605 )
606 })
607}
608
609fn init_logger() {
610 if std::env::var("RUST_LOG").is_ok() {
611 env_logger::try_init().ok();
612 }
613}
614
615async fn init_test(
616 cx: &mut TestAppContext,
617 server_cx: &mut TestAppContext,
618) -> (Model<Project>, Model<HeadlessProject>, Arc<FakeFs>) {
619 let (ssh_remote_client, ssh_server_client) = SshRemoteClient::fake(cx, server_cx);
620 init_logger();
621
622 let fs = FakeFs::new(server_cx.executor());
623 fs.insert_tree(
624 "/code",
625 json!({
626 "project1": {
627 ".git": {},
628 "README.md": "# project 1",
629 "src": {
630 "lib.rs": "fn one() -> usize { 1 }"
631 }
632 },
633 "project2": {
634 "README.md": "# project 2",
635 },
636 }),
637 )
638 .await;
639 fs.set_index_for_repo(
640 Path::new("/code/project1/.git"),
641 &[(Path::new("src/lib.rs"), "fn one() -> usize { 0 }".into())],
642 );
643
644 server_cx.update(HeadlessProject::init);
645 let headless =
646 server_cx.new_model(|cx| HeadlessProject::new(ssh_server_client, fs.clone(), cx));
647 let project = build_project(ssh_remote_client, cx);
648
649 project
650 .update(cx, {
651 let headless = headless.clone();
652 |_, cx| cx.on_release(|_, _| drop(headless))
653 })
654 .detach();
655 (project, headless, fs)
656}
657
658fn build_project(ssh: Model<SshRemoteClient>, cx: &mut TestAppContext) -> Model<Project> {
659 cx.update(|cx| {
660 let settings_store = SettingsStore::test(cx);
661 cx.set_global(settings_store);
662 });
663
664 let client = cx.update(|cx| {
665 Client::new(
666 Arc::new(FakeSystemClock::default()),
667 FakeHttpClient::with_404_response(),
668 cx,
669 )
670 });
671
672 let node = NodeRuntime::unavailable();
673 let user_store = cx.new_model(|cx| UserStore::new(client.clone(), cx));
674 let languages = Arc::new(LanguageRegistry::test(cx.executor()));
675 let fs = FakeFs::new(cx.executor());
676 cx.update(|cx| {
677 Project::init(&client, cx);
678 language::init(cx);
679 });
680
681 cx.update(|cx| Project::ssh(ssh, client, node, user_store, languages, fs, cx))
682}