Files
zed/crates/collab/tests/integration/editor_tests.rs
T
Ruslan SemaginandDanilo Leal b220d078bf Show tag names in git blame tooltips (#60757)
Fetch tag names for commits included in blame data and pass them through
the blame rendering path. Render tag names as chips in the blame hover
tooltip and expanded blame popover. Tag lookup is best-effort, so blame
still renders if tag lookup fails.

> Note: Tag names are currently only fetched for local repositories. For
remote projects (SSH) and collab sessions, blame data travels over the
`BlameBufferResponse` proto message, which doesn't carry tag names yet.

  ## Testing

- `cargo test -p git
test_parse_tag_names_for_lightweight_and_annotated_tags`
  - `cargo check -p git_ui`
  - `cargo check -p editor`
  - `cargo check -p project`

  Manual testing:
  - Opened `git: blame` on a file with a tagged commit.
- Verified tag chips are shown in the blame hover tooltip and expanded
popover.

  ## Self-Review Checklist:

  - [x] I've reviewed my own diff for quality, security, and reliability
  - [x] Unsafe blocks (if any) have justifying comments
- [x] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and [icon]

(https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
  - [x] Tests cover the new/changed behavior
  - [x] Performance impact has been considered and is acceptable

  ## Showcase

Before:
<img width="1655" height="660" alt="before"
src="https://github.com/user-attachments/assets/dbefd729-f8df-48a9-8249-83c93518fe76"
/>

After:
<img width="1655" height="660" alt="after"
src="https://github.com/user-attachments/assets/2c1097de-b268-45d9-9d13-0f3e06058603"
/>

---

Release Notes:

- Git: Made tags visible in Git blame tooltips.

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
2026-07-14 01:21:38 +00:00

6390 lines
230 KiB
Rust

use crate::TestServer;
use call::ActiveCall;
use collab::rpc::RECONNECT_TIMEOUT;
use collections::{HashMap, HashSet};
use editor::{
DocumentColorsRenderMode, Editor, LSP_REQUEST_DEBOUNCE_TIMEOUT, MultiBufferOffset, RowInfo,
SelectionEffects,
actions::{
ConfirmCodeAction, ConfirmCompletion, ConfirmRename, ContextMenuFirst, CopyFileLocation,
CopyFileName, CopyFileNameWithoutExtension, ExpandMacroRecursively, MoveToEnd, Redo,
Rename, SelectAll, ToggleCodeActions, Undo,
},
code_context_menus::CodeContextMenu,
test::{
editor_test_context::{AssertionContextManager, EditorTestContext},
expand_macro_recursively,
},
};
use fs::Fs;
use futures::{SinkExt, StreamExt, channel::mpsc, lock::Mutex};
use git::repository::repo_path;
use gpui::{
App, AppContext as _, Entity, Rgba, SharedString, TestAppContext, UpdateGlobal, VisualContext,
VisualTestContext,
};
use indoc::indoc;
use language::{FakeLspAdapter, language_settings::LanguageSettings, rust_lang};
use lsp::DEFAULT_LSP_REQUEST_TIMEOUT;
use multi_buffer::{AnchorRangeExt as _, MultiBufferRow};
use pretty_assertions::assert_eq;
use project::{
ProgressToken, ProjectPath, SERVER_PROGRESS_THROTTLE_TIMEOUT,
lsp_store::lsp_ext_command::{ExpandedMacro, LspExtExpandMacro},
trusted_worktrees::{PathTrust, TrustedWorktrees},
};
use recent_projects::disconnected_overlay::DisconnectedOverlay;
use rpc::RECEIVE_TIMEOUT;
use serde_json::json;
use settings::{
DocumentFoldingRanges, DocumentSymbols, InlayHintSettingsContent, InlineBlameSettings,
SemanticTokens, SettingsStore,
};
use std::{
collections::BTreeSet,
num::NonZeroU32,
ops::{Deref as _, Range},
path::{Path, PathBuf},
str::FromStr as _,
sync::{
Arc,
atomic::{self, AtomicBool, AtomicUsize},
},
time::Duration,
};
use text::Point;
use util::{path, rel_path::rel_path, uri};
use workspace::item::Item as _;
use workspace::{CloseIntent, MultiWorkspace, Workspace};
#[gpui::test(iterations = 10)]
async fn test_host_disconnect(
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
cx_c: &mut TestAppContext,
) {
let mut server = TestServer::start(cx_a.executor()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
let client_c = server.create_client(cx_c, "user_c").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)])
.await;
cx_b.update(editor::init);
cx_b.update(recent_projects::init);
client_a
.fs()
.insert_tree(
path!("/a"),
json!({
"a.txt": "a-contents",
"b.txt": "b-contents",
}),
)
.await;
let active_call_a = cx_a.read(ActiveCall::global);
let (project_a, worktree_id) = client_a.build_local_project(path!("/a"), cx_a).await;
let worktree_a = project_a.read_with(cx_a, |project, cx| project.worktrees(cx).next().unwrap());
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
let project_b = client_b.join_remote_project(project_id, cx_b).await;
cx_a.background_executor.run_until_parked();
assert!(worktree_a.read_with(cx_a, |tree, _| tree.has_update_observer()));
let window_b = cx_b.add_window(|window, cx| {
let workspace = cx.new(|cx| {
Workspace::new(
None,
project_b.clone(),
client_b.app_state.clone(),
window,
cx,
)
});
MultiWorkspace::new(workspace, window, cx)
});
let cx_b = &mut VisualTestContext::from_window(*window_b, cx_b);
let workspace_b = window_b
.root(cx_b)
.unwrap()
.read_with(cx_b, |multi_workspace, _| {
multi_workspace.workspace().clone()
});
let editor_b: Entity<Editor> = workspace_b
.update_in(cx_b, |workspace, window, cx| {
workspace.open_path((worktree_id, rel_path("b.txt")), None, true, window, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
//TODO: focus
assert!(
cx_b.update_window_entity(&editor_b, |editor: &mut Editor, window, _| editor
.is_focused(window))
);
editor_b.update_in(cx_b, |editor: &mut Editor, window, cx| {
editor.insert("X", window, cx)
});
cx_b.update(|_, cx| {
assert!(workspace_b.read(cx).is_edited());
});
// Drop client A's connection. Collaborators should disappear and the project should not be shown as shared.
server.forbid_connections();
server.disconnect_client(client_a.peer_id().unwrap());
cx_a.background_executor
.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT);
project_a.read_with(cx_a, |project, _| project.collaborators().is_empty());
project_a.read_with(cx_a, |project, _| assert!(!project.is_shared()));
project_b.read_with(cx_b, |project, cx| project.is_read_only(cx));
assert!(worktree_a.read_with(cx_a, |tree, _| !tree.has_update_observer()));
// Ensure client B's edited state is reset and that the whole window is blurred.
workspace_b.update(cx_b, |workspace, cx| {
assert!(workspace.active_modal::<DisconnectedOverlay>(cx).is_some());
assert!(!workspace.is_edited());
});
// Ensure client B is not prompted to save edits when closing window after disconnecting.
let can_close: bool = workspace_b
.update_in(cx_b, |workspace, window, cx| {
workspace.prepare_to_close(CloseIntent::Quit, window, cx)
})
.await
.unwrap();
assert!(can_close);
// Allow client A to reconnect to the server.
server.allow_connections();
cx_a.background_executor.advance_clock(RECONNECT_TIMEOUT);
// Client B calls client A again after they reconnected.
let active_call_b = cx_b.read(ActiveCall::global);
active_call_b
.update(cx_b, |call, cx| {
call.invite(client_a.user_id().unwrap(), None, cx)
})
.await
.unwrap();
cx_a.background_executor.run_until_parked();
active_call_a
.update(cx_a, |call, cx| call.accept_incoming(cx))
.await
.unwrap();
active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
// Drop client A's connection again. We should still unshare it successfully.
server.forbid_connections();
server.disconnect_client(client_a.peer_id().unwrap());
cx_a.background_executor
.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT);
project_a.read_with(cx_a, |project, _| assert!(!project.is_shared()));
}
#[gpui::test]
async fn test_newline_above_or_below_does_not_move_guest_cursor(
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
let mut server = TestServer::start(cx_a.executor()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
let executor = cx_a.executor();
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
let active_call_a = cx_a.read(ActiveCall::global);
client_a
.fs()
.insert_tree(path!("/dir"), json!({ "a.txt": "Some text\n" }))
.await;
let (project_a, worktree_id) = client_a.build_local_project(path!("/dir"), cx_a).await;
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
let project_b = client_b.join_remote_project(project_id, cx_b).await;
// Open a buffer as client A
let buffer_a = project_a
.update(cx_a, |p, cx| {
p.open_buffer((worktree_id, rel_path("a.txt")), cx)
})
.await
.unwrap();
let cx_a = cx_a.add_empty_window();
let editor_a = cx_a
.new_window_entity(|window, cx| Editor::for_buffer(buffer_a, Some(project_a), window, cx));
let mut editor_cx_a = EditorTestContext {
cx: cx_a.clone(),
window: cx_a.window_handle(),
editor: editor_a,
assertion_cx: AssertionContextManager::new(),
};
let cx_b = cx_b.add_empty_window();
// Open a buffer as client B
let buffer_b = project_b
.update(cx_b, |p, cx| {
p.open_buffer((worktree_id, rel_path("a.txt")), cx)
})
.await
.unwrap();
let editor_b = cx_b
.new_window_entity(|window, cx| Editor::for_buffer(buffer_b, Some(project_b), window, cx));
let mut editor_cx_b = EditorTestContext {
cx: cx_b.clone(),
window: cx_b.window_handle(),
editor: editor_b,
assertion_cx: AssertionContextManager::new(),
};
// Test newline above
editor_cx_a.set_selections_state(indoc! {"
Some textˇ
"});
editor_cx_b.set_selections_state(indoc! {"
Some textˇ
"});
editor_cx_a.update_editor(|editor, window, cx| {
editor.newline_above(&editor::actions::NewlineAbove, window, cx)
});
executor.run_until_parked();
editor_cx_a.assert_editor_state(indoc! {"
ˇ
Some text
"});
editor_cx_b.assert_editor_state(indoc! {"
Some textˇ
"});
// Test newline below
editor_cx_a.set_selections_state(indoc! {"
Some textˇ
"});
editor_cx_b.set_selections_state(indoc! {"
Some textˇ
"});
editor_cx_a.update_editor(|editor, window, cx| {
editor.newline_below(&editor::actions::NewlineBelow, window, cx)
});
executor.run_until_parked();
editor_cx_a.assert_editor_state(indoc! {"
Some text
ˇ
"});
editor_cx_b.assert_editor_state(indoc! {"
Some textˇ
"});
}
#[gpui::test]
async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
let mut server = TestServer::start(cx_a.executor()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
let active_call_a = cx_a.read(ActiveCall::global);
let capabilities = lsp::ServerCapabilities {
completion_provider: Some(lsp::CompletionOptions {
trigger_characters: Some(vec![".".to_string()]),
resolve_provider: Some(true),
..lsp::CompletionOptions::default()
}),
..lsp::ServerCapabilities::default()
};
client_a.language_registry().add(rust_lang());
let mut fake_language_servers = [
client_a.language_registry().register_fake_lsp(
"Rust",
FakeLspAdapter {
capabilities: capabilities.clone(),
initializer: Some(Box::new(|fake_server| {
fake_server.set_request_handler::<lsp::request::Completion, _, _>(
|params, _| async move {
assert_eq!(
params.text_document_position.text_document.uri,
lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(),
);
assert_eq!(
params.text_document_position.position,
lsp::Position::new(0, 14),
);
Ok(Some(lsp::CompletionResponse::Array(vec![
lsp::CompletionItem {
label: "first_method(…)".into(),
detail: Some("fn(&mut self, B) -> C".into()),
text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
new_text: "first_method($1)".to_string(),
range: lsp::Range::new(
lsp::Position::new(0, 14),
lsp::Position::new(0, 14),
),
})),
insert_text_format: Some(lsp::InsertTextFormat::SNIPPET),
..Default::default()
},
lsp::CompletionItem {
label: "second_method(…)".into(),
detail: Some("fn(&mut self, C) -> D<E>".into()),
text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
new_text: "second_method()".to_string(),
range: lsp::Range::new(
lsp::Position::new(0, 14),
lsp::Position::new(0, 14),
),
})),
insert_text_format: Some(lsp::InsertTextFormat::SNIPPET),
..Default::default()
},
])))
},
);
})),
..FakeLspAdapter::default()
},
),
client_a.language_registry().register_fake_lsp(
"Rust",
FakeLspAdapter {
name: "fake-analyzer",
capabilities: capabilities.clone(),
initializer: Some(Box::new(|fake_server| {
fake_server.set_request_handler::<lsp::request::Completion, _, _>(
|_, _| async move { Ok(None) },
);
})),
..FakeLspAdapter::default()
},
),
];
client_b.language_registry().add(rust_lang());
client_b.language_registry().register_fake_lsp_adapter(
"Rust",
FakeLspAdapter {
capabilities: capabilities.clone(),
..FakeLspAdapter::default()
},
);
client_b.language_registry().register_fake_lsp_adapter(
"Rust",
FakeLspAdapter {
name: "fake-analyzer",
capabilities,
..FakeLspAdapter::default()
},
);
client_a
.fs()
.insert_tree(
path!("/a"),
json!({
"main.rs": "fn main() { a }",
"other.rs": "",
}),
)
.await;
let (project_a, worktree_id) = client_a.build_local_project(path!("/a"), cx_a).await;
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
let project_b = client_b.join_remote_project(project_id, cx_b).await;
// Open a file in an editor as the guest.
let buffer_b = project_b
.update(cx_b, |p, cx| {
p.open_buffer((worktree_id, rel_path("main.rs")), cx)
})
.await
.unwrap();
let cx_b = cx_b.add_empty_window();
let editor_b = cx_b.new_window_entity(|window, cx| {
Editor::for_buffer(buffer_b.clone(), Some(project_b.clone()), window, cx)
});
let fake_language_server = fake_language_servers[0].next().await.unwrap();
let second_fake_language_server = fake_language_servers[1].next().await.unwrap();
cx_a.background_executor.run_until_parked();
cx_b.background_executor.run_until_parked();
buffer_b.read_with(cx_b, |buffer, _| {
assert!(!buffer.completion_triggers().is_empty())
});
// Set up the completion request handlers BEFORE typing the trigger character.
// This is critical - the handlers must be in place when the request arrives,
// otherwise the requests will time out waiting for a response.
let mut first_completion_request = fake_language_server
.set_request_handler::<lsp::request::Completion, _, _>(|params, _| async move {
assert_eq!(
params.text_document_position.text_document.uri,
lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(),
);
assert_eq!(
params.text_document_position.position,
lsp::Position::new(0, 14),
);
Ok(Some(lsp::CompletionResponse::Array(vec![
lsp::CompletionItem {
label: "first_method(…)".into(),
detail: Some("fn(&mut self, B) -> C".into()),
text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
new_text: "first_method($1)".to_string(),
range: lsp::Range::new(
lsp::Position::new(0, 14),
lsp::Position::new(0, 14),
),
})),
insert_text_format: Some(lsp::InsertTextFormat::SNIPPET),
..Default::default()
},
lsp::CompletionItem {
label: "second_method(…)".into(),
detail: Some("fn(&mut self, C) -> D<E>".into()),
text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
new_text: "second_method()".to_string(),
range: lsp::Range::new(
lsp::Position::new(0, 14),
lsp::Position::new(0, 14),
),
})),
insert_text_format: Some(lsp::InsertTextFormat::SNIPPET),
..Default::default()
},
])))
});
let mut second_completion_request = second_fake_language_server
.set_request_handler::<lsp::request::Completion, _, _>(|_, _| async move { Ok(None) });
// Type a completion trigger character as the guest.
editor_b.update_in(cx_b, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges([MultiBufferOffset(13)..MultiBufferOffset(13)])
});
editor.handle_input(".", window, cx);
});
cx_b.focus(&editor_b);
// Allow the completion request to propagate from guest to host to LSP.
cx_b.background_executor.run_until_parked();
cx_a.background_executor.run_until_parked();
// Wait for the completion requests to be received by the fake language servers.
first_completion_request.next().await.unwrap();
second_completion_request.next().await.unwrap();
// Open the buffer on the host.
let buffer_a = project_a
.update(cx_a, |p, cx| {
p.open_buffer((worktree_id, rel_path("main.rs")), cx)
})
.await
.unwrap();
cx_a.executor().run_until_parked();
buffer_a.read_with(cx_a, |buffer, _| {
assert_eq!(buffer.text(), "fn main() { a. }")
});
// Confirm a completion on the guest.
editor_b.update_in(cx_b, |editor, window, cx| {
assert!(editor.context_menu_visible());
editor.confirm_completion(&ConfirmCompletion { item_ix: Some(0) }, window, cx);
assert_eq!(editor.text(cx), "fn main() { a.first_method() }");
});
// Return a resolved completion from the host's language server.
// The resolved completion has an additional text edit.
fake_language_server.set_request_handler::<lsp::request::ResolveCompletionItem, _, _>(
|params, _| async move {
assert_eq!(params.label, "first_method(…)");
Ok(lsp::CompletionItem {
label: "first_method(…)".into(),
detail: Some("fn(&mut self, B) -> C".into()),
text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
new_text: "first_method($1)".to_string(),
range: lsp::Range::new(lsp::Position::new(0, 14), lsp::Position::new(0, 14)),
})),
additional_text_edits: Some(vec![lsp::TextEdit {
new_text: "use d::SomeTrait;\n".to_string(),
range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 0)),
}]),
insert_text_format: Some(lsp::InsertTextFormat::SNIPPET),
..Default::default()
})
},
);
// The additional edit is applied.
cx_a.executor().run_until_parked();
cx_b.executor().run_until_parked();
buffer_a.read_with(cx_a, |buffer, _| {
assert_eq!(
buffer.text(),
"use d::SomeTrait;\nfn main() { a.first_method() }"
);
});
buffer_b.read_with(cx_b, |buffer, _| {
assert_eq!(
buffer.text(),
"use d::SomeTrait;\nfn main() { a.first_method() }"
);
});
// Now we do a second completion, this time to ensure that documentation/snippets are
// resolved
editor_b.update_in(cx_b, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges([MultiBufferOffset(46)..MultiBufferOffset(46)])
});
editor.handle_input("; a", window, cx);
editor.handle_input(".", window, cx);
});
buffer_b.read_with(cx_b, |buffer, _| {
assert_eq!(
buffer.text(),
"use d::SomeTrait;\nfn main() { a.first_method(); a. }"
);
});
let mut completion_response = fake_language_server
.set_request_handler::<lsp::request::Completion, _, _>(|params, _| async move {
assert_eq!(
params.text_document_position.text_document.uri,
lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(),
);
assert_eq!(
params.text_document_position.position,
lsp::Position::new(1, 32),
);
Ok(Some(lsp::CompletionResponse::Array(vec![
lsp::CompletionItem {
label: "third_method(…)".into(),
detail: Some("fn(&mut self, B, C, D) -> E".into()),
text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
// no snippet placeholders
new_text: "third_method".to_string(),
range: lsp::Range::new(
lsp::Position::new(1, 32),
lsp::Position::new(1, 32),
),
})),
insert_text_format: Some(lsp::InsertTextFormat::SNIPPET),
documentation: None,
..Default::default()
},
])))
});
// Second language server also needs to handle the request (returns None)
let mut second_completion_response = second_fake_language_server
.set_request_handler::<lsp::request::Completion, _, _>(|_, _| async move { Ok(None) });
// The completion now gets a new `text_edit.new_text` when resolving the completion item
let mut resolve_completion_response = fake_language_server
.set_request_handler::<lsp::request::ResolveCompletionItem, _, _>(|params, _| async move {
assert_eq!(params.label, "third_method(…)");
Ok(lsp::CompletionItem {
label: "third_method(…)".into(),
detail: Some("fn(&mut self, B, C, D) -> E".into()),
text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
// Now it's a snippet
new_text: "third_method($1, $2, $3)".to_string(),
range: lsp::Range::new(lsp::Position::new(1, 32), lsp::Position::new(1, 32)),
})),
insert_text_format: Some(lsp::InsertTextFormat::SNIPPET),
documentation: Some(lsp::Documentation::String(
"this is the documentation".into(),
)),
..Default::default()
})
});
cx_b.executor().run_until_parked();
completion_response.next().await.unwrap();
second_completion_response.next().await.unwrap();
editor_b.update_in(cx_b, |editor, window, cx| {
assert!(editor.context_menu_visible());
editor.context_menu_first(&ContextMenuFirst {}, window, cx);
});
resolve_completion_response.next().await.unwrap();
cx_b.executor().run_until_parked();
// When accepting the completion, the snippet is insert.
editor_b.update_in(cx_b, |editor, window, cx| {
assert!(editor.context_menu_visible());
editor.confirm_completion(&ConfirmCompletion { item_ix: Some(0) }, window, cx);
assert_eq!(
editor.text(cx),
"use d::SomeTrait;\nfn main() { a.first_method(); a.third_method(, , ) }"
);
});
// Ensure buffer is synced before proceeding with the next test
cx_a.executor().run_until_parked();
cx_b.executor().run_until_parked();
// Test completions from the second fake language server
// Add another completion trigger to test the second language server
editor_b.update_in(cx_b, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges([MultiBufferOffset(68)..MultiBufferOffset(68)])
});
editor.handle_input("; b", window, cx);
editor.handle_input(".", window, cx);
});
buffer_b.read_with(cx_b, |buffer, _| {
assert_eq!(
buffer.text(),
"use d::SomeTrait;\nfn main() { a.first_method(); a.third_method(, , ); b. }"
);
});
// Set up completion handlers for both language servers
let mut first_lsp_completion = fake_language_server
.set_request_handler::<lsp::request::Completion, _, _>(|_, _| async move { Ok(None) });
let mut second_lsp_completion = second_fake_language_server
.set_request_handler::<lsp::request::Completion, _, _>(|params, _| async move {
assert_eq!(
params.text_document_position.text_document.uri,
lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(),
);
assert_eq!(
params.text_document_position.position,
lsp::Position::new(1, 54),
);
Ok(Some(lsp::CompletionResponse::Array(vec![
lsp::CompletionItem {
label: "analyzer_method(…)".into(),
detail: Some("fn(&self) -> Result<T>".into()),
text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
new_text: "analyzer_method()".to_string(),
range: lsp::Range::new(
lsp::Position::new(1, 54),
lsp::Position::new(1, 54),
),
})),
insert_text_format: Some(lsp::InsertTextFormat::SNIPPET),
..lsp::CompletionItem::default()
},
])))
});
// Await both language server responses
first_lsp_completion.next().await.unwrap();
second_lsp_completion.next().await.unwrap();
cx_b.executor().run_until_parked();
// Confirm the completion from the second language server works
editor_b.update_in(cx_b, |editor, window, cx| {
assert!(editor.context_menu_visible());
editor.confirm_completion(&ConfirmCompletion { item_ix: Some(0) }, window, cx);
assert_eq!(
editor.text(cx),
"use d::SomeTrait;\nfn main() { a.first_method(); a.third_method(, , ); b.analyzer_method() }"
);
});
}
#[gpui::test(iterations = 10)]
async fn test_collaborating_with_code_actions(
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
let mut server = TestServer::start(cx_a.executor()).await;
let client_a = server.create_client(cx_a, "user_a").await;
//
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
let active_call_a = cx_a.read(ActiveCall::global);
cx_b.update(editor::init);
client_a.language_registry().add(rust_lang());
let mut fake_language_servers = client_a
.language_registry()
.register_fake_lsp("Rust", FakeLspAdapter::default());
client_b.language_registry().add(rust_lang());
client_b
.language_registry()
.register_fake_lsp("Rust", FakeLspAdapter::default());
client_a
.fs()
.insert_tree(
path!("/a"),
json!({
"main.rs": "mod other;\nfn main() { let foo = other::foo(); }",
"other.rs": "pub fn foo() -> usize { 4 }",
}),
)
.await;
let (project_a, worktree_id) = client_a.build_local_project(path!("/a"), cx_a).await;
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
// Join the project as client B.
let project_b = client_b.join_remote_project(project_id, cx_b).await;
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
let editor_b = workspace_b
.update_in(cx_b, |workspace, window, cx| {
workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
let mut fake_language_server = fake_language_servers.next().await.unwrap();
let mut requests = fake_language_server
.set_request_handler::<lsp::request::CodeActionRequest, _, _>(|params, _| async move {
assert_eq!(
params.text_document.uri,
lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(),
);
assert_eq!(params.range.start, lsp::Position::new(0, 0));
assert_eq!(params.range.end, lsp::Position::new(0, 0));
Ok(None)
});
cx_a.background_executor
.advance_clock(editor::CODE_ACTIONS_DEBOUNCE_TIMEOUT * 2);
requests.next().await;
// Move cursor to a location that contains code actions.
editor_b.update_in(cx_b, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges([Point::new(1, 31)..Point::new(1, 31)])
});
});
cx_b.focus(&editor_b);
let mut requests = fake_language_server
.set_request_handler::<lsp::request::CodeActionRequest, _, _>(|params, _| async move {
assert_eq!(
params.text_document.uri,
lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(),
);
assert_eq!(params.range.start, lsp::Position::new(1, 31));
assert_eq!(params.range.end, lsp::Position::new(1, 31));
Ok(Some(vec![lsp::CodeActionOrCommand::CodeAction(
lsp::CodeAction {
title: "Inline into all callers".to_string(),
edit: Some(lsp::WorkspaceEdit {
changes: Some(
[
(
lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(),
vec![lsp::TextEdit::new(
lsp::Range::new(
lsp::Position::new(1, 22),
lsp::Position::new(1, 34),
),
"4".to_string(),
)],
),
(
lsp::Uri::from_file_path(path!("/a/other.rs")).unwrap(),
vec![lsp::TextEdit::new(
lsp::Range::new(
lsp::Position::new(0, 0),
lsp::Position::new(0, 27),
),
"".to_string(),
)],
),
]
.into_iter()
.collect(),
),
..Default::default()
}),
data: Some(json!({
"codeActionParams": {
"range": {
"start": {"line": 1, "column": 31},
"end": {"line": 1, "column": 31},
}
}
})),
..Default::default()
},
)]))
});
cx_a.background_executor
.advance_clock(editor::CODE_ACTIONS_DEBOUNCE_TIMEOUT * 2);
requests.next().await;
// Toggle code actions and wait for them to display.
editor_b.update_in(cx_b, |editor, window, cx| {
editor.toggle_code_actions(
&ToggleCodeActions {
deployed_from: None,
quick_launch: false,
},
window,
cx,
);
});
cx_a.background_executor.run_until_parked();
editor_b.update(cx_b, |editor, _| assert!(editor.context_menu_visible()));
fake_language_server.remove_request_handler::<lsp::request::CodeActionRequest>();
// Confirming the code action will trigger a resolve request.
let confirm_action = editor_b
.update_in(cx_b, |editor, window, cx| {
Editor::confirm_code_action(editor, &ConfirmCodeAction { item_ix: Some(0) }, window, cx)
})
.unwrap();
fake_language_server.set_request_handler::<lsp::request::CodeActionResolveRequest, _, _>(
|_, _| async move {
Ok(lsp::CodeAction {
title: "Inline into all callers".to_string(),
edit: Some(lsp::WorkspaceEdit {
changes: Some(
[
(
lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(),
vec![lsp::TextEdit::new(
lsp::Range::new(
lsp::Position::new(1, 22),
lsp::Position::new(1, 34),
),
"4".to_string(),
)],
),
(
lsp::Uri::from_file_path(path!("/a/other.rs")).unwrap(),
vec![lsp::TextEdit::new(
lsp::Range::new(
lsp::Position::new(0, 0),
lsp::Position::new(0, 27),
),
"".to_string(),
)],
),
]
.into_iter()
.collect(),
),
..Default::default()
}),
..Default::default()
})
},
);
// After the action is confirmed, an editor containing both modified files is opened.
confirm_action.await.unwrap();
let code_action_editor = workspace_b.update(cx_b, |workspace, cx| {
workspace
.active_item(cx)
.unwrap()
.downcast::<Editor>()
.unwrap()
});
code_action_editor.update_in(cx_b, |editor, window, cx| {
assert_eq!(editor.text(cx), "mod other;\nfn main() { let foo = 4; }\n");
editor.undo(&Undo, window, cx);
assert_eq!(
editor.text(cx),
"mod other;\nfn main() { let foo = other::foo(); }\npub fn foo() -> usize { 4 }"
);
editor.redo(&Redo, window, cx);
assert_eq!(editor.text(cx), "mod other;\nfn main() { let foo = 4; }\n");
});
}
#[gpui::test(iterations = 10)]
async fn test_collaborating_with_renames(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
let mut server = TestServer::start(cx_a.executor()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
let active_call_a = cx_a.read(ActiveCall::global);
cx_b.update(editor::init);
let capabilities = lsp::ServerCapabilities {
rename_provider: Some(lsp::OneOf::Right(lsp::RenameOptions {
prepare_provider: Some(true),
work_done_progress_options: Default::default(),
})),
..lsp::ServerCapabilities::default()
};
client_a.language_registry().add(rust_lang());
let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
"Rust",
FakeLspAdapter {
capabilities: capabilities.clone(),
..FakeLspAdapter::default()
},
);
client_b.language_registry().add(rust_lang());
client_b.language_registry().register_fake_lsp_adapter(
"Rust",
FakeLspAdapter {
capabilities,
..FakeLspAdapter::default()
},
);
client_a
.fs()
.insert_tree(
path!("/dir"),
json!({
"one.rs": "const ONE: usize = 1;",
"two.rs": "const TWO: usize = one::ONE + one::ONE;"
}),
)
.await;
let (project_a, worktree_id) = client_a.build_local_project(path!("/dir"), cx_a).await;
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
let project_b = client_b.join_remote_project(project_id, cx_b).await;
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
let editor_b = workspace_b
.update_in(cx_b, |workspace, window, cx| {
workspace.open_path((worktree_id, rel_path("one.rs")), None, true, window, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
let fake_language_server = fake_language_servers.next().await.unwrap();
cx_a.run_until_parked();
cx_b.run_until_parked();
// Move cursor to a location that can be renamed.
let prepare_rename = editor_b.update_in(cx_b, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges([MultiBufferOffset(7)..MultiBufferOffset(7)])
});
editor.rename(&Rename, window, cx).unwrap()
});
fake_language_server
.set_request_handler::<lsp::request::PrepareRenameRequest, _, _>(|params, _| async move {
assert_eq!(
params.text_document.uri.as_str(),
uri!("file:///dir/one.rs")
);
assert_eq!(params.position, lsp::Position::new(0, 7));
Ok(Some(lsp::PrepareRenameResponse::Range(lsp::Range::new(
lsp::Position::new(0, 6),
lsp::Position::new(0, 9),
))))
})
.next()
.await
.unwrap();
prepare_rename.await.unwrap();
editor_b.update(cx_b, |editor, cx| {
use editor::ToOffset;
let rename = editor.pending_rename().unwrap();
let buffer = editor.buffer().read(cx).snapshot(cx);
assert_eq!(
rename.range.start.to_offset(&buffer)..rename.range.end.to_offset(&buffer),
MultiBufferOffset(6)..MultiBufferOffset(9)
);
rename.editor.update(cx, |rename_editor, cx| {
let rename_selection = rename_editor.selections.newest::<MultiBufferOffset>(&rename_editor.display_snapshot(cx));
assert_eq!(
rename_selection.range(),
MultiBufferOffset(0)..MultiBufferOffset(3),
"Rename that was triggered from zero selection caret, should propose the whole word."
);
rename_editor.buffer().update(cx, |rename_buffer, cx| {
rename_buffer.edit([(MultiBufferOffset(0)..MultiBufferOffset(3), "THREE")], None, cx);
});
});
});
// Cancel the rename, and repeat the same, but use selections instead of cursor movement
editor_b.update_in(cx_b, |editor, window, cx| {
editor.cancel(&editor::actions::Cancel, window, cx);
});
let prepare_rename = editor_b.update_in(cx_b, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges([MultiBufferOffset(7)..MultiBufferOffset(8)])
});
editor.rename(&Rename, window, cx).unwrap()
});
fake_language_server
.set_request_handler::<lsp::request::PrepareRenameRequest, _, _>(|params, _| async move {
assert_eq!(
params.text_document.uri.as_str(),
uri!("file:///dir/one.rs")
);
assert_eq!(params.position, lsp::Position::new(0, 8));
Ok(Some(lsp::PrepareRenameResponse::Range(lsp::Range::new(
lsp::Position::new(0, 6),
lsp::Position::new(0, 9),
))))
})
.next()
.await
.unwrap();
prepare_rename.await.unwrap();
editor_b.update(cx_b, |editor, cx| {
use editor::ToOffset;
let rename = editor.pending_rename().unwrap();
let buffer = editor.buffer().read(cx).snapshot(cx);
let lsp_rename_start = rename.range.start.to_offset(&buffer);
let lsp_rename_end = rename.range.end.to_offset(&buffer);
assert_eq!(lsp_rename_start..lsp_rename_end, MultiBufferOffset(6)..MultiBufferOffset(9));
rename.editor.update(cx, |rename_editor, cx| {
let rename_selection = rename_editor.selections.newest::<MultiBufferOffset>(&rename_editor.display_snapshot(cx));
assert_eq!(
rename_selection.range(),
MultiBufferOffset(1)..MultiBufferOffset(2),
"Rename that was triggered from a selection, should have the same selection range in the rename proposal"
);
rename_editor.buffer().update(cx, |rename_buffer, cx| {
rename_buffer.edit([(MultiBufferOffset(0)..MultiBufferOffset(lsp_rename_end - lsp_rename_start), "THREE")], None, cx);
});
});
});
let confirm_rename = editor_b.update_in(cx_b, |editor, window, cx| {
Editor::confirm_rename(editor, &ConfirmRename, window, cx).unwrap()
});
fake_language_server
.set_request_handler::<lsp::request::Rename, _, _>(|params, _| async move {
assert_eq!(
params.text_document_position.text_document.uri.as_str(),
uri!("file:///dir/one.rs")
);
assert_eq!(
params.text_document_position.position,
lsp::Position::new(0, 6)
);
assert_eq!(params.new_name, "THREE");
Ok(Some(lsp::WorkspaceEdit {
changes: Some(
[
(
lsp::Uri::from_file_path(path!("/dir/one.rs")).unwrap(),
vec![lsp::TextEdit::new(
lsp::Range::new(lsp::Position::new(0, 6), lsp::Position::new(0, 9)),
"THREE".to_string(),
)],
),
(
lsp::Uri::from_file_path(path!("/dir/two.rs")).unwrap(),
vec![
lsp::TextEdit::new(
lsp::Range::new(
lsp::Position::new(0, 24),
lsp::Position::new(0, 27),
),
"THREE".to_string(),
),
lsp::TextEdit::new(
lsp::Range::new(
lsp::Position::new(0, 35),
lsp::Position::new(0, 38),
),
"THREE".to_string(),
),
],
),
]
.into_iter()
.collect(),
),
..Default::default()
}))
})
.next()
.await
.unwrap();
confirm_rename.await.unwrap();
let rename_editor = workspace_b.update(cx_b, |workspace, cx| {
workspace.active_item_as::<Editor>(cx).unwrap()
});
rename_editor.update_in(cx_b, |editor, window, cx| {
assert_eq!(
editor.text(cx),
"const THREE: usize = 1;\nconst TWO: usize = one::THREE + one::THREE;"
);
editor.undo(&Undo, window, cx);
assert_eq!(
editor.text(cx),
"const ONE: usize = 1;\nconst TWO: usize = one::ONE + one::ONE;"
);
editor.redo(&Redo, window, cx);
assert_eq!(
editor.text(cx),
"const THREE: usize = 1;\nconst TWO: usize = one::THREE + one::THREE;"
);
});
// Ensure temporary rename edits cannot be undone/redone.
editor_b.update_in(cx_b, |editor, window, cx| {
editor.undo(&Undo, window, cx);
assert_eq!(editor.text(cx), "const ONE: usize = 1;");
editor.undo(&Undo, window, cx);
assert_eq!(editor.text(cx), "const ONE: usize = 1;");
editor.redo(&Redo, window, cx);
assert_eq!(editor.text(cx), "const THREE: usize = 1;");
})
}
#[gpui::test]
async fn test_slow_lsp_server(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
let mut server = TestServer::start(cx_a.executor()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
let active_call_a = cx_a.read(ActiveCall::global);
cx_b.update(editor::init);
cx_b.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings.editor.code_lens = Some(settings::CodeLens::Menu);
});
});
});
let command_name = "test_command";
let capabilities = lsp::ServerCapabilities {
code_lens_provider: Some(lsp::CodeLensOptions {
resolve_provider: None,
}),
execute_command_provider: Some(lsp::ExecuteCommandOptions {
commands: vec![command_name.to_string()],
..lsp::ExecuteCommandOptions::default()
}),
..lsp::ServerCapabilities::default()
};
client_a.language_registry().add(rust_lang());
let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
"Rust",
FakeLspAdapter {
capabilities: capabilities.clone(),
..FakeLspAdapter::default()
},
);
client_b.language_registry().add(rust_lang());
client_b.language_registry().register_fake_lsp_adapter(
"Rust",
FakeLspAdapter {
capabilities,
..FakeLspAdapter::default()
},
);
client_a
.fs()
.insert_tree(
path!("/dir"),
json!({
"one.rs": "const ONE: usize = 1;"
}),
)
.await;
let (project_a, worktree_id) = client_a.build_local_project(path!("/dir"), cx_a).await;
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
let project_b = client_b.join_remote_project(project_id, cx_b).await;
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
let editor_b = workspace_b
.update_in(cx_b, |workspace, window, cx| {
workspace.open_path((worktree_id, rel_path("one.rs")), None, true, window, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
let (lsp_store_b, buffer_b) = editor_b.update(cx_b, |editor, cx| {
let lsp_store = editor.project().unwrap().read(cx).lsp_store();
let buffer = editor.buffer().read(cx).as_singleton().unwrap();
(lsp_store, buffer)
});
let fake_language_server = fake_language_servers.next().await.unwrap();
cx_a.run_until_parked();
cx_b.run_until_parked();
let long_request_time = DEFAULT_LSP_REQUEST_TIMEOUT / 2;
let (request_started_tx, mut request_started_rx) = mpsc::unbounded();
let requests_started = Arc::new(AtomicUsize::new(0));
let requests_completed = Arc::new(AtomicUsize::new(0));
let _lens_requests = fake_language_server
.set_request_handler::<lsp::request::CodeLensRequest, _, _>({
let request_started_tx = request_started_tx.clone();
let requests_started = requests_started.clone();
let requests_completed = requests_completed.clone();
move |params, cx| {
let mut request_started_tx = request_started_tx.clone();
let requests_started = requests_started.clone();
let requests_completed = requests_completed.clone();
async move {
assert_eq!(
params.text_document.uri.as_str(),
uri!("file:///dir/one.rs")
);
requests_started.fetch_add(1, atomic::Ordering::Release);
request_started_tx.send(()).await.unwrap();
cx.background_executor().timer(long_request_time).await;
let i = requests_completed.fetch_add(1, atomic::Ordering::Release) + 1;
Ok(Some(vec![lsp::CodeLens {
range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 9)),
command: Some(lsp::Command {
title: format!("LSP Command {i}"),
command: command_name.to_string(),
arguments: None,
}),
data: None,
}]))
}
}
});
// Move cursor to a location, this should trigger the code lens call.
editor_b.update_in(cx_b, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges([MultiBufferOffset(7)..MultiBufferOffset(7)])
});
});
let () = request_started_rx.next().await.unwrap();
assert_eq!(
requests_started.load(atomic::Ordering::Acquire),
1,
"Selection change should have initiated the first request"
);
assert_eq!(
requests_completed.load(atomic::Ordering::Acquire),
0,
"Slow requests should be running still"
);
let _first_task = lsp_store_b.update(cx_b, |lsp_store, cx| {
lsp_store
.forget_code_lens_task(buffer_b.read(cx).remote_id())
.expect("Should have the fetch task started")
});
editor_b.update_in(cx_b, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges([MultiBufferOffset(1)..MultiBufferOffset(1)])
});
});
let () = request_started_rx.next().await.unwrap();
assert_eq!(
requests_started.load(atomic::Ordering::Acquire),
2,
"Selection change should have initiated the second request"
);
assert_eq!(
requests_completed.load(atomic::Ordering::Acquire),
0,
"Slow requests should be running still"
);
let _second_task = lsp_store_b.update(cx_b, |lsp_store, cx| {
lsp_store
.forget_code_lens_task(buffer_b.read(cx).remote_id())
.expect("Should have the fetch task started for the 2nd time")
});
editor_b.update_in(cx_b, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges([MultiBufferOffset(2)..MultiBufferOffset(2)])
});
});
let () = request_started_rx.next().await.unwrap();
assert_eq!(
requests_started.load(atomic::Ordering::Acquire),
3,
"Selection change should have initiated the third request"
);
assert_eq!(
requests_completed.load(atomic::Ordering::Acquire),
0,
"Slow requests should be running still"
);
_first_task.await.unwrap();
_second_task.await.unwrap();
cx_b.run_until_parked();
assert_eq!(
requests_started.load(atomic::Ordering::Acquire),
3,
"No selection changes should trigger no more code lens requests"
);
assert_eq!(
requests_completed.load(atomic::Ordering::Acquire),
1,
"After enough time, a single, deduplicated, LSP request should have been served by the language server"
);
let resulting_lens_actions = editor_b
.update(cx_b, |editor, cx| {
let lsp_store = editor.project().unwrap().read(cx).lsp_store();
lsp_store.update(cx, |lsp_store, cx| {
lsp_store.code_lens_actions(&buffer_b, cx)
})
})
.await
.unwrap()
.unwrap();
assert_eq!(
resulting_lens_actions.len(),
1,
"Should have fetched one code lens action, but got: {resulting_lens_actions:?}"
);
assert_eq!(
resulting_lens_actions
.values()
.next()
.unwrap()
.lsp_action
.title(),
"LSP Command 1",
"Only the final code lens action should be in the data"
)
}
#[gpui::test]
async fn test_collaborating_with_code_lens_resolve(
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
let mut server = TestServer::start(cx_a.executor()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
let active_call_a = cx_a.read(ActiveCall::global);
cx_b.update(editor::init);
cx_b.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings.editor.code_lens = Some(settings::CodeLens::Menu);
});
});
});
let capabilities = lsp::ServerCapabilities {
code_lens_provider: Some(lsp::CodeLensOptions {
resolve_provider: Some(true),
}),
..lsp::ServerCapabilities::default()
};
client_a.language_registry().add(rust_lang());
client_a.language_registry().register_fake_lsp(
"Rust",
FakeLspAdapter {
capabilities: capabilities.clone(),
initializer: Some(Box::new(|fake_lsp| {
fake_lsp.set_request_handler::<lsp::request::CodeLensRequest, _, _>(
|_, _| async move {
Ok(Some(vec![lsp::CodeLens {
range: lsp::Range::new(
lsp::Position::new(0, 0),
lsp::Position::new(0, 9),
),
command: None,
data: Some(serde_json::json!({ "id": "lens" })),
}]))
},
);
fake_lsp.set_request_handler::<lsp::request::CodeLensResolve, _, _>(
|lens, _| async move {
Ok(lsp::CodeLens {
command: Some(lsp::Command {
title: "1 reference".to_string(),
command: "noop".to_string(),
arguments: None,
}),
..lens
})
},
);
})),
..FakeLspAdapter::default()
},
);
client_b.language_registry().add(rust_lang());
client_b.language_registry().register_fake_lsp_adapter(
"Rust",
FakeLspAdapter {
capabilities,
..FakeLspAdapter::default()
},
);
client_a
.fs()
.insert_tree(
path!("/dir"),
json!({
"one.rs": "const ONE: usize = 1;"
}),
)
.await;
let (project_a, worktree_id) = client_a.build_local_project(path!("/dir"), cx_a).await;
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
let project_b = client_b.join_remote_project(project_id, cx_b).await;
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
let editor_b = workspace_b
.update_in(cx_b, |workspace, window, cx| {
workspace.open_path((worktree_id, rel_path("one.rs")), None, true, window, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
cx_a.run_until_parked();
cx_b.run_until_parked();
editor_b.update_in(cx_b, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges([Point::new(0, 0)..Point::new(0, 0)]);
});
});
cx_a.background_executor
.advance_clock(editor::CODE_ACTIONS_DEBOUNCE_TIMEOUT * 2);
cx_a.run_until_parked();
cx_b.run_until_parked();
editor_b.update_in(cx_b, |editor, window, cx| {
editor.toggle_code_actions(
&ToggleCodeActions {
deployed_from: None,
quick_launch: false,
},
window,
cx,
);
});
cx_a.run_until_parked();
cx_b.run_until_parked();
editor_b.update(cx_b, |editor, _| {
assert!(editor.context_menu_visible());
let menu = editor.context_menu().borrow();
let actions_menu = match menu.as_ref() {
Some(CodeContextMenu::CodeActions(m)) => m,
_ => panic!("Expected code actions menu to be visible"),
};
let item = actions_menu
.actions
.get(0)
.expect("Expected at least one item in menu");
assert_eq!(item.label(), "1 reference");
});
}
#[gpui::test(iterations = 10)]
async fn test_language_server_statuses(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
let mut server = TestServer::start(cx_a.executor()).await;
let executor = cx_a.executor();
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
let active_call_a = cx_a.read(ActiveCall::global);
cx_b.update(editor::init);
client_a.language_registry().add(rust_lang());
let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
"Rust",
FakeLspAdapter {
name: "the-language-server",
..Default::default()
},
);
client_a
.fs()
.insert_tree(
path!("/dir"),
json!({
"main.rs": "const ONE: usize = 1;",
}),
)
.await;
let (project_a, _) = client_a.build_local_project(path!("/dir"), cx_a).await;
let _buffer_a = project_a
.update(cx_a, |p, cx| {
p.open_local_buffer_with_lsp(path!("/dir/main.rs"), cx)
})
.await
.unwrap();
let fake_language_server = fake_language_servers.next().await.unwrap();
executor.run_until_parked();
fake_language_server.start_progress("the-token").await;
executor.advance_clock(SERVER_PROGRESS_THROTTLE_TIMEOUT);
fake_language_server.notify::<lsp::notification::Progress>(lsp::ProgressParams {
token: lsp::NumberOrString::String("the-token".to_string()),
value: lsp::ProgressParamsValue::WorkDone(lsp::WorkDoneProgress::Report(
lsp::WorkDoneProgressReport {
message: Some("the-message".to_string()),
..Default::default()
},
)),
});
executor.run_until_parked();
let token = ProgressToken::String(SharedString::from("the-token"));
project_a.read_with(cx_a, |project, cx| {
let status = project.language_server_statuses(cx).next().unwrap().1;
assert_eq!(status.name.0, "the-language-server");
assert_eq!(status.pending_work.len(), 1);
assert_eq!(
status.pending_work[&token].message.as_ref().unwrap(),
"the-message"
);
});
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
executor.run_until_parked();
let project_b = client_b.join_remote_project(project_id, cx_b).await;
project_b.read_with(cx_b, |project, cx| {
let status = project.language_server_statuses(cx).next().unwrap().1;
assert_eq!(status.name.0, "the-language-server");
});
executor.advance_clock(SERVER_PROGRESS_THROTTLE_TIMEOUT);
fake_language_server.notify::<lsp::notification::Progress>(lsp::ProgressParams {
token: lsp::NumberOrString::String("the-token".to_string()),
value: lsp::ProgressParamsValue::WorkDone(lsp::WorkDoneProgress::Report(
lsp::WorkDoneProgressReport {
message: Some("the-message-2".to_string()),
..Default::default()
},
)),
});
executor.run_until_parked();
project_a.read_with(cx_a, |project, cx| {
let status = project.language_server_statuses(cx).next().unwrap().1;
assert_eq!(status.name.0, "the-language-server");
assert_eq!(status.pending_work.len(), 1);
assert_eq!(
status.pending_work[&token].message.as_ref().unwrap(),
"the-message-2"
);
});
project_b.read_with(cx_b, |project, cx| {
let status = project.language_server_statuses(cx).next().unwrap().1;
assert_eq!(status.name.0, "the-language-server");
assert_eq!(status.pending_work.len(), 1);
assert_eq!(
status.pending_work[&token].message.as_ref().unwrap(),
"the-message-2"
);
});
}
#[gpui::test]
async fn test_local_registration_for_new_available_server_from_remote(
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
let mut server = TestServer::start(cx_a.executor()).await;
let executor = cx_a.executor();
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
let active_call_a = cx_a.read(ActiveCall::global);
client_a.language_registry().add(rust_lang());
client_b.language_registry().add(rust_lang());
// Client B has an "available" adapter for "the-language-server",
// but it's not regitstered for Rust
client_b
.language_registry()
.register_fake_available_lsp_adapter(
"the-language-server",
FakeLspAdapter {
name: "the-language-server",
..Default::default()
},
);
client_a
.fs()
.insert_tree(
path!("/dir"),
json!({
"main.rs": "const ONE: usize = 1;",
}),
)
.await;
let (project_a, _) = client_a.build_local_project(path!("/dir"), cx_a).await;
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
executor.run_until_parked();
let project_b = client_b.join_remote_project(project_id, cx_b).await;
// Client A starts the language server.
let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
"Rust",
FakeLspAdapter {
name: "the-language-server",
..Default::default()
},
);
let _buffer_a = project_a
.update(cx_a, |p, cx| {
p.open_local_buffer_with_lsp(path!("/dir/main.rs"), cx)
})
.await
.unwrap();
let _fake_language_server = fake_language_servers.next().await.unwrap();
executor.run_until_parked();
// Verify client B has registered the adapter for Rust locally
project_b.read_with(cx_b, |project, cx| {
let statuses = project.language_server_statuses(cx).collect::<Vec<_>>();
assert_eq!(statuses.len(), 1);
assert_eq!(statuses[0].1.name.0, "the-language-server");
});
let rust_adapters = client_b
.language_registry()
.lsp_adapters(&language::LanguageName::new("Rust"));
assert!(
rust_adapters
.iter()
.any(|a| a.name().0 == "the-language-server")
);
}
#[gpui::test]
async fn test_local_registration_for_existing_available_server_from_remote(
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
let mut server = TestServer::start(cx_a.executor()).await;
let executor = cx_a.executor();
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
let active_call_a = cx_a.read(ActiveCall::global);
client_a.language_registry().add(rust_lang());
client_b.language_registry().add(rust_lang());
// Client B has an "available" adapter for "the-language-server",
// but it's not regitstered for Rust
client_b
.language_registry()
.register_fake_available_lsp_adapter(
"the-language-server",
FakeLspAdapter {
name: "the-language-server",
..Default::default()
},
);
client_a
.fs()
.insert_tree(
path!("/dir"),
json!({
"main.rs": "const ONE: usize = 1;",
}),
)
.await;
let (project_a, _) = client_a.build_local_project(path!("/dir"), cx_a).await;
// Client A starts the language server FIRST.
let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
"Rust",
FakeLspAdapter {
name: "the-language-server",
..Default::default()
},
);
let _buffer_a = project_a
.update(cx_a, |p, cx| {
p.open_local_buffer_with_lsp(path!("/dir/main.rs"), cx)
})
.await
.unwrap();
let _fake_language_server = fake_language_servers.next().await.unwrap();
executor.run_until_parked();
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
executor.run_until_parked();
// Client B joins the remote project.
let project_b = client_b.join_remote_project(project_id, cx_b).await;
executor.run_until_parked();
// Verify client B has registered the adapter for Rust locally.
let rust_adapters = client_b
.language_registry()
.lsp_adapters(&language::LanguageName::new("Rust"));
assert!(
rust_adapters
.iter()
.any(|a| a.name().0 == "the-language-server"),
"Adapter should have been registered upon joining"
);
project_b.read_with(cx_b, |project, cx| {
let statuses = project.language_server_statuses(cx).collect::<Vec<_>>();
assert_eq!(statuses.len(), 1);
assert_eq!(statuses[0].1.name.0, "the-language-server");
});
}
#[gpui::test(iterations = 10)]
async fn test_share_project(
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
cx_c: &mut TestAppContext,
) {
let executor = cx_a.executor();
let cx_b = cx_b.add_empty_window();
let mut server = TestServer::start(executor.clone()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
let client_c = server.create_client(cx_c, "user_c").await;
server
.make_contacts(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)])
.await;
let active_call_a = cx_a.read(ActiveCall::global);
let active_call_b = cx_b.read(ActiveCall::global);
let active_call_c = cx_c.read(ActiveCall::global);
client_a
.fs()
.insert_tree(
path!("/a"),
json!({
".gitignore": "ignored-dir",
"a.txt": "a-contents",
"b.txt": "b-contents",
"ignored-dir": {
"c.txt": "",
"d.txt": "",
}
}),
)
.await;
// Invite client B to collaborate on a project
let (project_a, worktree_id) = client_a.build_local_project(path!("/a"), cx_a).await;
active_call_a
.update(cx_a, |call, cx| {
call.invite(client_b.user_id().unwrap(), Some(project_a.clone()), cx)
})
.await
.unwrap();
// Join that project as client B
let incoming_call_b = active_call_b.read_with(cx_b, |call, _| call.incoming());
executor.run_until_parked();
let call = incoming_call_b.borrow().clone().unwrap();
assert_eq!(call.calling_user.username, "user_a");
let initial_project = call.initial_project.unwrap();
active_call_b
.update(cx_b, |call, cx| call.accept_incoming(cx))
.await
.unwrap();
let client_b_peer_id = client_b.peer_id().unwrap();
let project_b = client_b.join_remote_project(initial_project.id, cx_b).await;
let replica_id_b = project_b.read_with(cx_b, |project, _| project.replica_id());
executor.run_until_parked();
project_a.read_with(cx_a, |project, _| {
let client_b_collaborator = project.collaborators().get(&client_b_peer_id).unwrap();
assert_eq!(client_b_collaborator.replica_id, replica_id_b);
});
project_b.read_with(cx_b, |project, cx| {
let worktree = project.worktrees(cx).next().unwrap().read(cx);
assert_eq!(
worktree.paths().collect::<Vec<_>>(),
[
rel_path(".gitignore"),
rel_path("a.txt"),
rel_path("b.txt"),
rel_path("ignored-dir"),
]
);
});
project_b
.update(cx_b, |project, cx| {
let worktree = project.worktrees(cx).next().unwrap();
let entry = worktree
.read(cx)
.entry_for_path(rel_path("ignored-dir"))
.unwrap();
project.expand_entry(worktree_id, entry.id, cx).unwrap()
})
.await
.unwrap();
project_b.read_with(cx_b, |project, cx| {
let worktree = project.worktrees(cx).next().unwrap().read(cx);
assert_eq!(
worktree.paths().collect::<Vec<_>>(),
[
rel_path(".gitignore"),
rel_path("a.txt"),
rel_path("b.txt"),
rel_path("ignored-dir"),
rel_path("ignored-dir/c.txt"),
rel_path("ignored-dir/d.txt"),
]
);
});
// Open the same file as client B and client A.
let buffer_b = project_b
.update(cx_b, |p, cx| {
p.open_buffer((worktree_id, rel_path("b.txt")), cx)
})
.await
.unwrap();
buffer_b.read_with(cx_b, |buf, _| assert_eq!(buf.text(), "b-contents"));
project_a.read_with(cx_a, |project, cx| {
assert!(project.has_open_buffer((worktree_id, rel_path("b.txt")), cx))
});
let buffer_a = project_a
.update(cx_a, |p, cx| {
p.open_buffer((worktree_id, rel_path("b.txt")), cx)
})
.await
.unwrap();
let editor_b =
cx_b.new_window_entity(|window, cx| Editor::for_buffer(buffer_b, None, window, cx));
// Client A sees client B's selection
executor.run_until_parked();
buffer_a.read_with(cx_a, |buffer, _| {
buffer
.snapshot()
.selections_in_range(
text::Anchor::min_max_range_for_buffer(buffer.remote_id()),
false,
)
.count()
== 1
});
// Edit the buffer as client B and see that edit as client A.
editor_b.update_in(cx_b, |editor, window, cx| {
editor.handle_input("ok, ", window, cx)
});
executor.run_until_parked();
buffer_a.read_with(cx_a, |buffer, _| {
assert_eq!(buffer.text(), "ok, b-contents")
});
// Client B can invite client C on a project shared by client A.
active_call_b
.update(cx_b, |call, cx| {
call.invite(client_c.user_id().unwrap(), Some(project_b.clone()), cx)
})
.await
.unwrap();
let incoming_call_c = active_call_c.read_with(cx_c, |call, _| call.incoming());
executor.run_until_parked();
let call = incoming_call_c.borrow().clone().unwrap();
assert_eq!(call.calling_user.username, "user_b");
let initial_project = call.initial_project.unwrap();
active_call_c
.update(cx_c, |call, cx| call.accept_incoming(cx))
.await
.unwrap();
let _project_c = client_c.join_remote_project(initial_project.id, cx_c).await;
// Client B closes the editor, and client A sees client B's selections removed.
cx_b.update(move |_, _| drop(editor_b));
executor.run_until_parked();
buffer_a.read_with(cx_a, |buffer, _| {
buffer
.snapshot()
.selections_in_range(
text::Anchor::min_max_range_for_buffer(buffer.remote_id()),
false,
)
.count()
== 0
});
}
#[gpui::test(iterations = 10)]
async fn test_on_input_format_from_host_to_guest(
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
let mut server = TestServer::start(cx_a.executor()).await;
let executor = cx_a.executor();
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
let active_call_a = cx_a.read(ActiveCall::global);
client_a.language_registry().add(rust_lang());
let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
"Rust",
FakeLspAdapter {
capabilities: lsp::ServerCapabilities {
document_on_type_formatting_provider: Some(lsp::DocumentOnTypeFormattingOptions {
first_trigger_character: ":".to_string(),
more_trigger_character: Some(vec![">".to_string()]),
}),
..Default::default()
},
..Default::default()
},
);
client_a
.fs()
.insert_tree(
path!("/a"),
json!({
"main.rs": "fn main() { a }",
"other.rs": "// Test file",
}),
)
.await;
let (project_a, worktree_id) = client_a.build_local_project(path!("/a"), cx_a).await;
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
let project_b = client_b.join_remote_project(project_id, cx_b).await;
// Open a file in an editor as the host.
let buffer_a = project_a
.update(cx_a, |p, cx| {
p.open_buffer((worktree_id, rel_path("main.rs")), cx)
})
.await
.unwrap();
let cx_a = cx_a.add_empty_window();
let editor_a = cx_a.new_window_entity(|window, cx| {
Editor::for_buffer(buffer_a, Some(project_a.clone()), window, cx)
});
let fake_language_server = fake_language_servers.next().await.unwrap();
executor.run_until_parked();
// Receive an OnTypeFormatting request as the host's language server.
// Return some formatting from the host's language server.
fake_language_server.set_request_handler::<lsp::request::OnTypeFormatting, _, _>(
|params, _| async move {
assert_eq!(
params.text_document_position.text_document.uri,
lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(),
);
assert_eq!(
params.text_document_position.position,
lsp::Position::new(0, 14),
);
Ok(Some(vec![lsp::TextEdit {
new_text: "~<".to_string(),
range: lsp::Range::new(lsp::Position::new(0, 14), lsp::Position::new(0, 14)),
}]))
},
);
// Open the buffer on the guest and see that the formatting worked
let buffer_b = project_b
.update(cx_b, |p, cx| {
p.open_buffer((worktree_id, rel_path("main.rs")), cx)
})
.await
.unwrap();
// Type a on type formatting trigger character as the guest.
cx_a.focus(&editor_a);
editor_a.update_in(cx_a, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges([MultiBufferOffset(13)..MultiBufferOffset(13)])
});
editor.handle_input(">", window, cx);
});
executor.run_until_parked();
buffer_b.read_with(cx_b, |buffer, _| {
assert_eq!(buffer.text(), "fn main() { a>~< }")
});
// Undo should remove LSP edits first
editor_a.update_in(cx_a, |editor, window, cx| {
assert_eq!(editor.text(cx), "fn main() { a>~< }");
editor.undo(&Undo, window, cx);
assert_eq!(editor.text(cx), "fn main() { a> }");
});
executor.run_until_parked();
buffer_b.read_with(cx_b, |buffer, _| {
assert_eq!(buffer.text(), "fn main() { a> }")
});
editor_a.update_in(cx_a, |editor, window, cx| {
assert_eq!(editor.text(cx), "fn main() { a> }");
editor.undo(&Undo, window, cx);
assert_eq!(editor.text(cx), "fn main() { a }");
});
executor.run_until_parked();
buffer_b.read_with(cx_b, |buffer, _| {
assert_eq!(buffer.text(), "fn main() { a }")
});
}
#[gpui::test(iterations = 10)]
async fn test_on_input_format_from_guest_to_host(
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
let mut server = TestServer::start(cx_a.executor()).await;
let executor = cx_a.executor();
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
let active_call_a = cx_a.read(ActiveCall::global);
let capabilities = lsp::ServerCapabilities {
document_on_type_formatting_provider: Some(lsp::DocumentOnTypeFormattingOptions {
first_trigger_character: ":".to_string(),
more_trigger_character: Some(vec![">".to_string()]),
}),
..lsp::ServerCapabilities::default()
};
client_a.language_registry().add(rust_lang());
let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
"Rust",
FakeLspAdapter {
capabilities: capabilities.clone(),
..FakeLspAdapter::default()
},
);
client_b.language_registry().add(rust_lang());
client_b.language_registry().register_fake_lsp_adapter(
"Rust",
FakeLspAdapter {
capabilities,
..FakeLspAdapter::default()
},
);
client_a
.fs()
.insert_tree(
path!("/a"),
json!({
"main.rs": "fn main() { a }",
"other.rs": "// Test file",
}),
)
.await;
let (project_a, worktree_id) = client_a.build_local_project(path!("/a"), cx_a).await;
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
let project_b = client_b.join_remote_project(project_id, cx_b).await;
// Open a file in an editor as the guest.
let buffer_b = project_b
.update(cx_b, |p, cx| {
p.open_buffer((worktree_id, rel_path("main.rs")), cx)
})
.await
.unwrap();
let cx_b = cx_b.add_empty_window();
let editor_b = cx_b.new_window_entity(|window, cx| {
Editor::for_buffer(buffer_b, Some(project_b.clone()), window, cx)
});
let fake_language_server = fake_language_servers.next().await.unwrap();
executor.run_until_parked();
// Type a on type formatting trigger character as the guest.
cx_b.focus(&editor_b);
editor_b.update_in(cx_b, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges([MultiBufferOffset(13)..MultiBufferOffset(13)])
});
editor.handle_input(":", window, cx);
});
// Receive an OnTypeFormatting request as the host's language server.
// Return some formatting from the host's language server.
fake_language_server
.set_request_handler::<lsp::request::OnTypeFormatting, _, _>(|params, _| async move {
assert_eq!(
params.text_document_position.text_document.uri,
lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(),
);
assert_eq!(
params.text_document_position.position,
lsp::Position::new(0, 14),
);
Ok(Some(vec![lsp::TextEdit {
new_text: "~:".to_string(),
range: lsp::Range::new(lsp::Position::new(0, 14), lsp::Position::new(0, 14)),
}]))
})
.next()
.await
.unwrap();
// Open the buffer on the host and see that the formatting worked
let buffer_a = project_a
.update(cx_a, |p, cx| {
p.open_buffer((worktree_id, rel_path("main.rs")), cx)
})
.await
.unwrap();
executor.run_until_parked();
buffer_a.read_with(cx_a, |buffer, _| {
assert_eq!(buffer.text(), "fn main() { a:~: }")
});
// Undo should remove LSP edits first
editor_b.update_in(cx_b, |editor, window, cx| {
assert_eq!(editor.text(cx), "fn main() { a:~: }");
editor.undo(&Undo, window, cx);
assert_eq!(editor.text(cx), "fn main() { a: }");
});
executor.run_until_parked();
buffer_a.read_with(cx_a, |buffer, _| {
assert_eq!(buffer.text(), "fn main() { a: }")
});
editor_b.update_in(cx_b, |editor, window, cx| {
assert_eq!(editor.text(cx), "fn main() { a: }");
editor.undo(&Undo, window, cx);
assert_eq!(editor.text(cx), "fn main() { a }");
});
executor.run_until_parked();
buffer_a.read_with(cx_a, |buffer, _| {
assert_eq!(buffer.text(), "fn main() { a }")
});
}
#[gpui::test(iterations = 10)]
async fn test_mutual_editor_inlay_hint_cache_update(
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
let mut server = TestServer::start(cx_a.executor()).await;
let executor = cx_a.executor();
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
let active_call_a = cx_a.read(ActiveCall::global);
let active_call_b = cx_b.read(ActiveCall::global);
cx_a.update(editor::init);
cx_b.update(editor::init);
cx_a.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings.project.all_languages.defaults.inlay_hints =
Some(InlayHintSettingsContent {
enabled: Some(true),
..InlayHintSettingsContent::default()
})
});
});
});
cx_b.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings.project.all_languages.defaults.inlay_hints =
Some(InlayHintSettingsContent {
enabled: Some(true),
..InlayHintSettingsContent::default()
})
});
});
});
let capabilities = lsp::ServerCapabilities {
inlay_hint_provider: Some(lsp::OneOf::Left(true)),
..lsp::ServerCapabilities::default()
};
client_a.language_registry().add(rust_lang());
// Set up the language server to return an additional inlay hint on each request.
let edits_made = Arc::new(AtomicUsize::new(0));
let closure_edits_made = Arc::clone(&edits_made);
let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
"Rust",
FakeLspAdapter {
capabilities: capabilities.clone(),
initializer: Some(Box::new(move |fake_language_server| {
let closure_edits_made = closure_edits_made.clone();
fake_language_server.set_request_handler::<lsp::request::InlayHintRequest, _, _>(
move |params, _| {
let edits_made_2 = Arc::clone(&closure_edits_made);
async move {
assert_eq!(
params.text_document.uri,
lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(),
);
let edits_made =
AtomicUsize::load(&edits_made_2, atomic::Ordering::Acquire);
Ok(Some(vec![lsp::InlayHint {
position: lsp::Position::new(0, edits_made as u32),
label: lsp::InlayHintLabel::String(edits_made.to_string()),
kind: None,
text_edits: None,
tooltip: None,
padding_left: None,
padding_right: None,
data: None,
}]))
}
},
);
})),
..FakeLspAdapter::default()
},
);
client_b.language_registry().add(rust_lang());
client_b.language_registry().register_fake_lsp_adapter(
"Rust",
FakeLspAdapter {
capabilities,
..FakeLspAdapter::default()
},
);
// Client A opens a project.
client_a
.fs()
.insert_tree(
path!("/a"),
json!({
"main.rs": "fn main() { a } // and some long comment to ensure inlay hints are not trimmed out",
"other.rs": "// Test file",
}),
)
.await;
let (project_a, worktree_id) = client_a.build_local_project(path!("/a"), cx_a).await;
active_call_a
.update(cx_a, |call, cx| call.set_location(Some(&project_a), cx))
.await
.unwrap();
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
// Client B joins the project
let project_b = client_b.join_remote_project(project_id, cx_b).await;
active_call_b
.update(cx_b, |call, cx| call.set_location(Some(&project_b), cx))
.await
.unwrap();
let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a);
// The host opens a rust file.
let file_a = workspace_a.update_in(cx_a, |workspace, window, cx| {
workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx)
});
let fake_language_server = fake_language_servers.next().await.unwrap();
let editor_a = file_a.await.unwrap().downcast::<Editor>().unwrap();
executor.advance_clock(Duration::from_millis(100));
executor.run_until_parked();
let initial_edit = edits_made.load(atomic::Ordering::Acquire);
editor_a.update(cx_a, |editor, cx| {
assert_eq!(
vec![initial_edit.to_string()],
extract_hint_labels(editor, cx),
"Host should get its first hints when opens an editor"
);
});
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
let editor_b = workspace_b
.update_in(cx_b, |workspace, window, cx| {
workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
executor.advance_clock(Duration::from_millis(100));
executor.run_until_parked();
editor_b.update(cx_b, |editor, cx| {
assert_eq!(
vec![initial_edit.to_string()],
extract_hint_labels(editor, cx),
"Client should get its first hints when opens an editor"
);
});
let after_client_edit = edits_made.fetch_add(1, atomic::Ordering::Release) + 1;
editor_b.update_in(cx_b, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges([MultiBufferOffset(13)..MultiBufferOffset(13)].clone())
});
editor.handle_input(":", window, cx);
});
cx_b.focus(&editor_b);
executor.advance_clock(Duration::from_secs(1));
executor.run_until_parked();
editor_a.update(cx_a, |editor, cx| {
assert_eq!(
vec![after_client_edit.to_string()],
extract_hint_labels(editor, cx),
);
});
editor_b.update(cx_b, |editor, cx| {
assert_eq!(
vec![after_client_edit.to_string()],
extract_hint_labels(editor, cx),
);
});
let after_host_edit = edits_made.fetch_add(1, atomic::Ordering::Release) + 1;
editor_a.update_in(cx_a, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges([MultiBufferOffset(13)..MultiBufferOffset(13)])
});
editor.handle_input("a change to increment both buffers' versions", window, cx);
});
cx_a.focus(&editor_a);
executor.advance_clock(Duration::from_secs(1));
executor.run_until_parked();
editor_a.update(cx_a, |editor, cx| {
assert_eq!(
vec![after_host_edit.to_string()],
extract_hint_labels(editor, cx),
);
});
editor_b.update(cx_b, |editor, cx| {
assert_eq!(
vec![after_host_edit.to_string()],
extract_hint_labels(editor, cx),
);
});
let after_special_edit_for_refresh = edits_made.fetch_add(1, atomic::Ordering::Release) + 1;
fake_language_server
.request::<lsp::request::InlayHintRefreshRequest>((), DEFAULT_LSP_REQUEST_TIMEOUT)
.await
.into_response()
.expect("inlay refresh request failed");
executor.advance_clock(Duration::from_secs(1));
executor.run_until_parked();
editor_a.update(cx_a, |editor, cx| {
assert_eq!(
vec![after_special_edit_for_refresh.to_string()],
extract_hint_labels(editor, cx),
"Host should react to /refresh LSP request"
);
});
editor_b.update(cx_b, |editor, cx| {
assert_eq!(
vec![after_special_edit_for_refresh.to_string()],
extract_hint_labels(editor, cx),
"Guest should get a /refresh LSP request propagated by host"
);
});
}
#[gpui::test(iterations = 10)]
async fn test_inlay_hint_refresh_is_forwarded(
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
let mut server = TestServer::start(cx_a.executor()).await;
let executor = cx_a.executor();
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
let active_call_a = cx_a.read(ActiveCall::global);
let active_call_b = cx_b.read(ActiveCall::global);
cx_a.update(editor::init);
cx_b.update(editor::init);
cx_a.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings.project.all_languages.defaults.inlay_hints =
Some(InlayHintSettingsContent {
show_value_hints: Some(true),
enabled: Some(false),
edit_debounce_ms: Some(0),
scroll_debounce_ms: Some(0),
show_type_hints: Some(false),
show_parameter_hints: Some(false),
show_other_hints: Some(false),
show_background: Some(false),
toggle_on_modifiers_press: None,
})
});
});
});
cx_b.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings.project.all_languages.defaults.inlay_hints =
Some(InlayHintSettingsContent {
show_value_hints: Some(true),
enabled: Some(true),
edit_debounce_ms: Some(0),
scroll_debounce_ms: Some(0),
show_type_hints: Some(true),
show_parameter_hints: Some(true),
show_other_hints: Some(true),
show_background: Some(false),
toggle_on_modifiers_press: None,
})
});
});
});
let capabilities = lsp::ServerCapabilities {
inlay_hint_provider: Some(lsp::OneOf::Left(true)),
..lsp::ServerCapabilities::default()
};
client_a.language_registry().add(rust_lang());
let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
"Rust",
FakeLspAdapter {
capabilities: capabilities.clone(),
..FakeLspAdapter::default()
},
);
client_b.language_registry().add(rust_lang());
client_b.language_registry().register_fake_lsp_adapter(
"Rust",
FakeLspAdapter {
capabilities,
..FakeLspAdapter::default()
},
);
client_a
.fs()
.insert_tree(
path!("/a"),
json!({
"main.rs": "fn main() { a } // and some long comment to ensure inlay hints are not trimmed out",
"other.rs": "// Test file",
}),
)
.await;
let (project_a, worktree_id) = client_a.build_local_project(path!("/a"), cx_a).await;
active_call_a
.update(cx_a, |call, cx| call.set_location(Some(&project_a), cx))
.await
.unwrap();
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
let project_b = client_b.join_remote_project(project_id, cx_b).await;
active_call_b
.update(cx_b, |call, cx| call.set_location(Some(&project_b), cx))
.await
.unwrap();
let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a);
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
let editor_a = workspace_a
.update_in(cx_a, |workspace, window, cx| {
workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
let editor_b = workspace_b
.update_in(cx_b, |workspace, window, cx| {
workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
let other_hints = Arc::new(AtomicBool::new(false));
let fake_language_server = fake_language_servers.next().await.unwrap();
let closure_other_hints = Arc::clone(&other_hints);
fake_language_server
.set_request_handler::<lsp::request::InlayHintRequest, _, _>(move |params, _| {
let task_other_hints = Arc::clone(&closure_other_hints);
async move {
assert_eq!(
params.text_document.uri,
lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(),
);
let other_hints = task_other_hints.load(atomic::Ordering::Acquire);
let character = if other_hints { 0 } else { 2 };
let label = if other_hints {
"other hint"
} else {
"initial hint"
};
Ok(Some(vec![
lsp::InlayHint {
position: lsp::Position::new(0, character),
label: lsp::InlayHintLabel::String(label.to_string()),
kind: None,
text_edits: None,
tooltip: None,
padding_left: None,
padding_right: None,
data: None,
},
lsp::InlayHint {
position: lsp::Position::new(1090, 1090),
label: lsp::InlayHintLabel::String("out-of-bounds hint".to_string()),
kind: None,
text_edits: None,
tooltip: None,
padding_left: None,
padding_right: None,
data: None,
},
]))
}
})
.next()
.await
.unwrap();
executor.run_until_parked();
editor_a.update(cx_a, |editor, cx| {
assert!(
extract_hint_labels(editor, cx).is_empty(),
"Host should get no hints due to them turned off"
);
});
executor.run_until_parked();
editor_b.update(cx_b, |editor, cx| {
assert_eq!(
vec!["initial hint".to_string()],
extract_hint_labels(editor, cx),
"Client should get its first hints when opens an editor"
);
});
other_hints.fetch_or(true, atomic::Ordering::Release);
fake_language_server
.request::<lsp::request::InlayHintRefreshRequest>((), DEFAULT_LSP_REQUEST_TIMEOUT)
.await
.into_response()
.expect("inlay refresh request failed");
executor.run_until_parked();
editor_a.update(cx_a, |editor, cx| {
assert!(
extract_hint_labels(editor, cx).is_empty(),
"Host should get no hints due to them turned off, even after the /refresh"
);
});
executor.run_until_parked();
editor_b.update(cx_b, |editor, cx| {
assert_eq!(
vec!["other hint".to_string()],
extract_hint_labels(editor, cx),
"Guest should get a /refresh LSP request propagated by host despite host hints are off"
);
});
}
#[gpui::test(iterations = 10)]
async fn test_lsp_document_color(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
let expected_color = Rgba {
r: 0.33,
g: 0.33,
b: 0.33,
a: 0.33,
};
let mut server = TestServer::start(cx_a.executor()).await;
let executor = cx_a.executor();
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
let active_call_a = cx_a.read(ActiveCall::global);
let active_call_b = cx_b.read(ActiveCall::global);
cx_a.update(editor::init);
cx_b.update(editor::init);
cx_a.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings.editor.lsp_document_colors = Some(DocumentColorsRenderMode::None);
});
});
});
cx_b.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings.editor.lsp_document_colors = Some(DocumentColorsRenderMode::Inlay);
});
});
});
let capabilities = lsp::ServerCapabilities {
color_provider: Some(lsp::ColorProviderCapability::Simple(true)),
..lsp::ServerCapabilities::default()
};
client_a.language_registry().add(rust_lang());
let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
"Rust",
FakeLspAdapter {
capabilities: capabilities.clone(),
..FakeLspAdapter::default()
},
);
client_b.language_registry().add(rust_lang());
client_b.language_registry().register_fake_lsp_adapter(
"Rust",
FakeLspAdapter {
capabilities,
..FakeLspAdapter::default()
},
);
// Client A opens a project.
client_a
.fs()
.insert_tree(
path!("/a"),
json!({
"main.rs": "fn main() { a }",
}),
)
.await;
let (project_a, worktree_id) = client_a.build_local_project(path!("/a"), cx_a).await;
active_call_a
.update(cx_a, |call, cx| call.set_location(Some(&project_a), cx))
.await
.unwrap();
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
// Client B joins the project
let project_b = client_b.join_remote_project(project_id, cx_b).await;
active_call_b
.update(cx_b, |call, cx| call.set_location(Some(&project_b), cx))
.await
.unwrap();
let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a);
// The host opens a rust file.
let _buffer_a = project_a
.update(cx_a, |project, cx| {
project.open_local_buffer(path!("/a/main.rs"), cx)
})
.await
.unwrap();
let editor_a = workspace_a
.update_in(cx_a, |workspace, window, cx| {
workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
let fake_language_server = fake_language_servers.next().await.unwrap();
cx_a.run_until_parked();
cx_b.run_until_parked();
let requests_made = Arc::new(AtomicUsize::new(0));
let closure_requests_made = Arc::clone(&requests_made);
let mut color_request_handle = fake_language_server
.set_request_handler::<lsp::request::DocumentColor, _, _>(move |params, _| {
let requests_made = Arc::clone(&closure_requests_made);
async move {
assert_eq!(
params.text_document.uri,
lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(),
);
requests_made.fetch_add(1, atomic::Ordering::Release);
Ok(vec![lsp::ColorInformation {
range: lsp::Range {
start: lsp::Position {
line: 0,
character: 0,
},
end: lsp::Position {
line: 0,
character: 1,
},
},
color: lsp::Color {
red: 0.33,
green: 0.33,
blue: 0.33,
alpha: 0.33,
},
}])
}
});
executor.run_until_parked();
assert_eq!(
0,
requests_made.load(atomic::Ordering::Acquire),
"Host did not enable document colors, hence should query for none"
);
editor_a.update(cx_a, |editor, cx| {
assert_eq!(
Vec::<Rgba>::new(),
extract_color_inlays(editor, cx),
"No query colors should result in no hints"
);
});
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
let editor_b = workspace_b
.update_in(cx_b, |workspace, window, cx| {
workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
color_request_handle.next().await.unwrap();
executor.advance_clock(LSP_REQUEST_DEBOUNCE_TIMEOUT);
executor.run_until_parked();
assert_eq!(
1,
requests_made.load(atomic::Ordering::Acquire),
"The client opened the file and got its first colors back"
);
editor_b.update(cx_b, |editor, cx| {
assert_eq!(
vec![expected_color],
extract_color_inlays(editor, cx),
"With document colors as inlays, color inlays should be pushed"
);
});
editor_a.update_in(cx_a, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges([MultiBufferOffset(13)..MultiBufferOffset(13)].clone())
});
editor.handle_input(":", window, cx);
});
color_request_handle.next().await.unwrap();
executor.run_until_parked();
assert_eq!(
2,
requests_made.load(atomic::Ordering::Acquire),
"After the host edits his file, the client should request the colors again"
);
editor_a.update(cx_a, |editor, cx| {
assert_eq!(
Vec::<Rgba>::new(),
extract_color_inlays(editor, cx),
"Host has no colors still"
);
});
editor_b.update(cx_b, |editor, cx| {
assert_eq!(vec![expected_color], extract_color_inlays(editor, cx),);
});
cx_b.update(|_, cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings.editor.lsp_document_colors = Some(DocumentColorsRenderMode::Background);
});
});
});
executor.run_until_parked();
assert_eq!(
2,
requests_made.load(atomic::Ordering::Acquire),
"After the client have changed the colors settings, no extra queries should happen"
);
editor_a.update(cx_a, |editor, cx| {
assert_eq!(
Vec::<Rgba>::new(),
extract_color_inlays(editor, cx),
"Host is unaffected by the client's settings changes"
);
});
editor_b.update(cx_b, |editor, cx| {
assert_eq!(
Vec::<Rgba>::new(),
extract_color_inlays(editor, cx),
"Client should have no colors hints, as in the settings"
);
});
cx_b.update(|_, cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings.editor.lsp_document_colors = Some(DocumentColorsRenderMode::Inlay);
});
});
});
executor.run_until_parked();
assert_eq!(
2,
requests_made.load(atomic::Ordering::Acquire),
"After falling back to colors as inlays, no extra LSP queries are made"
);
editor_a.update(cx_a, |editor, cx| {
assert_eq!(
Vec::<Rgba>::new(),
extract_color_inlays(editor, cx),
"Host is unaffected by the client's settings changes, again"
);
});
editor_b.update(cx_b, |editor, cx| {
assert_eq!(
vec![expected_color],
extract_color_inlays(editor, cx),
"Client should have its color hints back"
);
});
cx_a.update(|_, cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings.editor.lsp_document_colors = Some(DocumentColorsRenderMode::Border);
});
});
});
color_request_handle.next().await.unwrap();
executor.run_until_parked();
assert_eq!(
3,
requests_made.load(atomic::Ordering::Acquire),
"After the host enables document colors, another LSP query should be made"
);
editor_a.update(cx_a, |editor, cx| {
assert_eq!(
Vec::<Rgba>::new(),
extract_color_inlays(editor, cx),
"Host did not configure document colors as hints hence gets nothing"
);
});
editor_b.update(cx_b, |editor, cx| {
assert_eq!(
vec![expected_color],
extract_color_inlays(editor, cx),
"Client should be unaffected by the host's settings changes"
);
});
}
#[gpui::test]
async fn test_lsp_document_links(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
let mut server = TestServer::start(cx_a.executor()).await;
let executor = cx_a.executor();
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
let active_call_a = cx_a.read(ActiveCall::global);
let active_call_b = cx_b.read(ActiveCall::global);
cx_a.update(editor::init);
cx_b.update(editor::init);
for cx in [&mut *cx_a, &mut *cx_b] {
cx.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings.editor.lsp_document_links = Some(true);
});
});
});
}
let capabilities = lsp::ServerCapabilities {
document_link_provider: Some(lsp::DocumentLinkOptions {
resolve_provider: Some(true),
work_done_progress_options: lsp::WorkDoneProgressOptions::default(),
}),
..lsp::ServerCapabilities::default()
};
client_a.language_registry().add(rust_lang());
let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
"Rust",
FakeLspAdapter {
capabilities: capabilities.clone(),
..FakeLspAdapter::default()
},
);
client_b.language_registry().add(rust_lang());
client_b.language_registry().register_fake_lsp_adapter(
"Rust",
FakeLspAdapter {
capabilities,
..FakeLspAdapter::default()
},
);
let other_contents = concat!(
"fn first() {}\n",
"fn second() {}\n",
"fn third(x: i32) {}\n",
"fn fourth() {}\n",
"fn fifth() {}\n",
);
client_a
.fs()
.insert_tree(
path!("/a"),
json!({
"main.rs": "// see LICENSE for details\nfn main() {}",
"other.rs": other_contents,
}),
)
.await;
let (project_a, worktree_id) = client_a.build_local_project(path!("/a"), cx_a).await;
active_call_a
.update(cx_a, |call, cx| call.set_location(Some(&project_a), cx))
.await
.unwrap();
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
let project_b = client_b.join_remote_project(project_id, cx_b).await;
active_call_b
.update(cx_b, |call, cx| call.set_location(Some(&project_b), cx))
.await
.unwrap();
let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a);
let _editor_a = workspace_a
.update_in(cx_a, |workspace, window, cx| {
workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
let fake_language_server = fake_language_servers.next().await.unwrap();
let link_range = lsp::Range {
start: lsp::Position {
line: 0,
character: 7,
},
end: lsp::Position {
line: 0,
character: 14,
},
};
let other_uri = lsp::Uri::from_file_path(path!("/a/other.rs")).unwrap();
// The server points at line 3, column 5 (1-based) of `other.rs` using the
// json-language-server fragment convention.
let other_uri_with_fragment =
lsp::Uri::from_str(&format!("{}#3,5", other_uri.as_str())).unwrap();
let other_target = other_uri_with_fragment.to_string();
let tooltip = "Open other.rs";
let resolve_marker = serde_json::json!({"id": 42});
let document_link_requests = Arc::new(AtomicUsize::new(0));
let document_link_count = Arc::clone(&document_link_requests);
let resolve_marker_for_links = resolve_marker.clone();
let mut document_link_handle = fake_language_server
.set_request_handler::<lsp::request::DocumentLinkRequest, _, _>(move |params, _| {
let document_link_count = Arc::clone(&document_link_count);
let resolve_marker = resolve_marker_for_links.clone();
async move {
assert_eq!(
params.text_document.uri,
lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(),
);
document_link_count.fetch_add(1, atomic::Ordering::Release);
Ok(Some(vec![lsp::DocumentLink {
range: link_range,
target: None,
tooltip: None,
data: Some(resolve_marker),
}]))
}
});
let resolve_requests = Arc::new(AtomicUsize::new(0));
let resolve_count = Arc::clone(&resolve_requests);
let other_uri_for_resolve = other_uri_with_fragment.clone();
let resolve_marker_for_resolve = resolve_marker.clone();
let _resolve_handle = fake_language_server
.set_request_handler::<lsp::request::DocumentLinkResolve, _, _>(move |link, _| {
let resolve_count = Arc::clone(&resolve_count);
let other_uri = other_uri_for_resolve.clone();
let expected_marker = resolve_marker_for_resolve.clone();
async move {
assert_eq!(link.range, link_range);
assert_eq!(link.data.as_ref(), Some(&expected_marker));
resolve_count.fetch_add(1, atomic::Ordering::Release);
Ok(lsp::DocumentLink {
range: link.range,
target: Some(other_uri),
tooltip: Some(tooltip.to_string()),
data: None,
})
}
});
document_link_handle.next().await.unwrap();
executor.advance_clock(LSP_REQUEST_DEBOUNCE_TIMEOUT);
executor.run_until_parked();
assert_eq!(
1,
document_link_requests.load(atomic::Ordering::Acquire),
"Host opening the file should issue exactly one documentLink request"
);
assert_eq!(
0,
resolve_requests.load(atomic::Ordering::Acquire),
"No resolve happens until a hover triggers it"
);
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
let editor_b = workspace_b
.update_in(cx_b, |workspace, window, cx| {
workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
executor.advance_clock(LSP_REQUEST_DEBOUNCE_TIMEOUT + Duration::from_millis(100));
executor.run_until_parked();
assert_eq!(
1,
document_link_requests.load(atomic::Ordering::Acquire),
"Guest's proto fetch should be served from the host's cached document links \
without issuing a fresh documentLink LSP request"
);
let guest_buffer = editor_b
.read_with(cx_b, |editor, cx| editor.buffer().read(cx).as_singleton())
.unwrap();
let buffer_id = guest_buffer.read_with(cx_b, |buffer, _| buffer.remote_id());
let unresolved = project_b
.read_with(cx_b, |project, cx| {
project
.lsp_store()
.read(cx)
.document_links_for_buffer(buffer_id)
.unwrap_or_default()
})
.into_values()
.flat_map(|per_server| per_server.into_values())
.next()
.expect("guest should mirror the fetched document link");
assert!(
!unresolved.resolved,
"freshly fetched links must come back unresolved"
);
let resolve_task = editor_b
.update(cx_b, |editor, cx| {
editor.document_links_at(guest_buffer.clone(), unresolved.range.start, cx)
})
.expect("editor should have a cached link covering the position");
let resolved_links = resolve_task.await;
assert_eq!(
1,
resolved_links.len(),
"`document_links_at` should yield the single matching link"
);
executor.run_until_parked();
assert_eq!(
1,
resolve_requests.load(atomic::Ordering::Acquire),
"Guest's resolve should reach the host's LSP exactly once"
);
let guest_links = project_b.read_with(cx_b, |project, cx| {
project
.lsp_store()
.read(cx)
.document_links_for_buffer(buffer_id)
.unwrap_or_default()
});
assert_eq!(
1,
guest_links.values().map(|m| m.len()).sum::<usize>(),
"Guest should mirror exactly one document link from the host"
);
let link = guest_links
.values()
.flat_map(|per_server| per_server.values())
.next()
.expect("guest cache should contain the mirrored link");
assert_eq!(
link.target.as_deref(),
Some(other_target.as_str()),
"Guest should see the resolved file:// target from the host"
);
assert_eq!(link.tooltip.as_deref(), Some(tooltip));
let click_anchor = guest_buffer.read_with(cx_b, |buffer, _| buffer.anchor_before(10));
let resolved_at_click = editor_b
.update(cx_b, |editor, cx| {
editor.document_links_at(guest_buffer.clone(), click_anchor, cx)
})
.expect("cached document link should cover the click anchor")
.await;
let (click_server_id, click_link) = resolved_at_click
.into_iter()
.next()
.expect("resolved links should not be empty");
let click_target = click_link
.target
.as_deref()
.expect("link should be resolved")
.to_owned();
let navigated = editor_b
.update_in(cx_b, |editor, window, cx| {
let hover_link = editor::hover_links::document_link_target_to_hover_link(
&click_target,
click_server_id,
);
editor.navigate_to_hover_links(None, vec![hover_link], None, false, window, cx)
})
.await
.expect("navigation task should complete");
assert_eq!(
navigated,
editor::Navigated::Yes,
"Clicking a resolved file:// document link should navigate",
);
executor.run_until_parked();
let other_editor = workspace_b.update(cx_b, |workspace, cx| {
workspace.active_item_as::<Editor>(cx).unwrap()
});
other_editor.update(cx_b, |editor, cx| {
let buffer = editor.buffer().read(cx).as_singleton().unwrap();
assert_eq!(
buffer.read(cx).text(),
other_contents,
"Following the resolved link should open other.rs from the same worktree",
);
let head = editor
.selections
.newest::<Point>(&editor.display_snapshot(cx))
.head();
assert_eq!(
head,
Point::new(2, 4),
"Cursor should land at the URI fragment's line/column (1-based 3,5 -> 0-based 2,4)",
);
});
}
async fn test_lsp_pull_diagnostics(
should_stream_workspace_diagnostic: bool,
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
let mut server = TestServer::start(cx_a.executor()).await;
let executor = cx_a.executor();
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
let active_call_a = cx_a.read(ActiveCall::global);
let active_call_b = cx_b.read(ActiveCall::global);
cx_a.update(editor::init);
cx_b.update(editor::init);
let expected_push_diagnostic_main_message = "pushed main diagnostic";
let expected_push_diagnostic_lib_message = "pushed lib diagnostic";
let expected_pull_diagnostic_main_message = "pulled main diagnostic";
let expected_pull_diagnostic_lib_message = "pulled lib diagnostic";
let expected_workspace_pull_diagnostics_main_message = "pulled workspace main diagnostic";
let expected_workspace_pull_diagnostics_lib_message = "pulled workspace lib diagnostic";
let diagnostics_pulls_result_ids = Arc::new(Mutex::new(BTreeSet::<Option<String>>::new()));
let workspace_diagnostics_pulls_result_ids = Arc::new(Mutex::new(BTreeSet::<String>::new()));
let diagnostics_pulls_made = Arc::new(AtomicUsize::new(0));
let closure_diagnostics_pulls_made = diagnostics_pulls_made.clone();
let closure_diagnostics_pulls_result_ids = diagnostics_pulls_result_ids.clone();
let workspace_diagnostics_pulls_made = Arc::new(AtomicUsize::new(0));
let closure_workspace_diagnostics_pulls_made = workspace_diagnostics_pulls_made.clone();
let closure_workspace_diagnostics_pulls_result_ids =
workspace_diagnostics_pulls_result_ids.clone();
let (workspace_diagnostic_cancel_tx, closure_workspace_diagnostic_cancel_rx) =
async_channel::bounded::<()>(1);
let (closure_workspace_diagnostic_received_tx, workspace_diagnostic_received_rx) =
async_channel::bounded::<()>(1);
let capabilities = lsp::ServerCapabilities {
diagnostic_provider: Some(lsp::DiagnosticServerCapabilities::Options(
lsp::DiagnosticOptions {
identifier: Some("test-pulls".to_string()),
inter_file_dependencies: true,
workspace_diagnostics: true,
work_done_progress_options: lsp::WorkDoneProgressOptions {
work_done_progress: None,
},
},
)),
..lsp::ServerCapabilities::default()
};
client_a.language_registry().add(rust_lang());
let pull_diagnostics_handle = Arc::new(parking_lot::Mutex::new(None));
let workspace_diagnostics_pulls_handle = Arc::new(parking_lot::Mutex::new(None));
let closure_pull_diagnostics_handle = pull_diagnostics_handle.clone();
let closure_workspace_diagnostics_pulls_handle = workspace_diagnostics_pulls_handle.clone();
let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
"Rust",
FakeLspAdapter {
capabilities: capabilities.clone(),
initializer: Some(Box::new(move |fake_language_server| {
let expected_workspace_diagnostic_token = lsp::ProgressToken::String(format!(
"workspace/diagnostic/{}/1",
fake_language_server.server.server_id()
));
let closure_workspace_diagnostics_pulls_result_ids = closure_workspace_diagnostics_pulls_result_ids.clone();
let diagnostics_pulls_made = closure_diagnostics_pulls_made.clone();
let diagnostics_pulls_result_ids = closure_diagnostics_pulls_result_ids.clone();
let closure_pull_diagnostics_handle = closure_pull_diagnostics_handle.clone();
let closure_workspace_diagnostics_pulls_handle = closure_workspace_diagnostics_pulls_handle.clone();
let closure_workspace_diagnostic_cancel_rx = closure_workspace_diagnostic_cancel_rx.clone();
let closure_workspace_diagnostic_received_tx = closure_workspace_diagnostic_received_tx.clone();
let pull_diagnostics_handle = fake_language_server
.set_request_handler::<lsp::request::DocumentDiagnosticRequest, _, _>(
move |params, _| {
let requests_made = diagnostics_pulls_made.clone();
let diagnostics_pulls_result_ids =
diagnostics_pulls_result_ids.clone();
async move {
let message = if lsp::Uri::from_file_path(path!("/a/main.rs"))
.unwrap()
== params.text_document.uri
{
expected_pull_diagnostic_main_message.to_string()
} else if lsp::Uri::from_file_path(path!("/a/lib.rs")).unwrap()
== params.text_document.uri
{
expected_pull_diagnostic_lib_message.to_string()
} else {
panic!("Unexpected document: {}", params.text_document.uri)
};
{
diagnostics_pulls_result_ids
.lock()
.await
.insert(params.previous_result_id);
}
let new_requests_count =
requests_made.fetch_add(1, atomic::Ordering::Release) + 1;
Ok(lsp::DocumentDiagnosticReportResult::Report(
lsp::DocumentDiagnosticReport::Full(
lsp::RelatedFullDocumentDiagnosticReport {
related_documents: None,
full_document_diagnostic_report:
lsp::FullDocumentDiagnosticReport {
result_id: Some(format!(
"pull-{new_requests_count}"
)),
items: vec![lsp::Diagnostic {
range: lsp::Range {
start: lsp::Position {
line: 0,
character: 0,
},
end: lsp::Position {
line: 0,
character: 2,
},
},
severity: Some(
lsp::DiagnosticSeverity::ERROR,
),
message,
..lsp::Diagnostic::default()
}],
},
},
),
))
}
},
);
let _ = closure_pull_diagnostics_handle.lock().insert(pull_diagnostics_handle);
let closure_workspace_diagnostics_pulls_made = closure_workspace_diagnostics_pulls_made.clone();
let workspace_diagnostics_pulls_handle = fake_language_server.set_request_handler::<lsp::request::WorkspaceDiagnosticRequest, _, _>(
move |params, _| {
let workspace_requests_made = closure_workspace_diagnostics_pulls_made.clone();
let workspace_diagnostics_pulls_result_ids =
closure_workspace_diagnostics_pulls_result_ids.clone();
let workspace_diagnostic_cancel_rx = closure_workspace_diagnostic_cancel_rx.clone();
let workspace_diagnostic_received_tx = closure_workspace_diagnostic_received_tx.clone();
let expected_workspace_diagnostic_token = expected_workspace_diagnostic_token.clone();
async move {
let workspace_request_count =
workspace_requests_made.fetch_add(1, atomic::Ordering::Release) + 1;
{
workspace_diagnostics_pulls_result_ids
.lock()
.await
.extend(params.previous_result_ids.into_iter().map(|id| id.value));
}
if should_stream_workspace_diagnostic && !workspace_diagnostic_cancel_rx.is_closed()
{
assert_eq!(
params.partial_result_params.partial_result_token,
Some(expected_workspace_diagnostic_token)
);
workspace_diagnostic_received_tx.send(()).await.unwrap();
workspace_diagnostic_cancel_rx.recv().await.unwrap();
workspace_diagnostic_cancel_rx.close();
// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#partialResults
// > The final response has to be empty in terms of result values.
return Ok(lsp::WorkspaceDiagnosticReportResult::Report(
lsp::WorkspaceDiagnosticReport { items: Vec::new() },
));
}
Ok(lsp::WorkspaceDiagnosticReportResult::Report(
lsp::WorkspaceDiagnosticReport {
items: vec![
lsp::WorkspaceDocumentDiagnosticReport::Full(
lsp::WorkspaceFullDocumentDiagnosticReport {
uri: lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(),
version: None,
full_document_diagnostic_report:
lsp::FullDocumentDiagnosticReport {
result_id: Some(format!(
"workspace_{workspace_request_count}"
)),
items: vec![lsp::Diagnostic {
range: lsp::Range {
start: lsp::Position {
line: 0,
character: 1,
},
end: lsp::Position {
line: 0,
character: 3,
},
},
severity: Some(lsp::DiagnosticSeverity::WARNING),
message:
expected_workspace_pull_diagnostics_main_message
.to_string(),
..lsp::Diagnostic::default()
}],
},
},
),
lsp::WorkspaceDocumentDiagnosticReport::Full(
lsp::WorkspaceFullDocumentDiagnosticReport {
uri: lsp::Uri::from_file_path(path!("/a/lib.rs")).unwrap(),
version: None,
full_document_diagnostic_report:
lsp::FullDocumentDiagnosticReport {
result_id: Some(format!(
"workspace_{workspace_request_count}"
)),
items: vec![lsp::Diagnostic {
range: lsp::Range {
start: lsp::Position {
line: 0,
character: 1,
},
end: lsp::Position {
line: 0,
character: 3,
},
},
severity: Some(lsp::DiagnosticSeverity::WARNING),
message:
expected_workspace_pull_diagnostics_lib_message
.to_string(),
..lsp::Diagnostic::default()
}],
},
},
),
],
},
))
}
});
let _ = closure_workspace_diagnostics_pulls_handle.lock().insert(workspace_diagnostics_pulls_handle);
})),
..FakeLspAdapter::default()
},
);
client_b.language_registry().add(rust_lang());
client_b.language_registry().register_fake_lsp_adapter(
"Rust",
FakeLspAdapter {
capabilities,
..FakeLspAdapter::default()
},
);
// Client A opens a project.
client_a
.fs()
.insert_tree(
path!("/a"),
json!({
"main.rs": "fn main() { a }",
"lib.rs": "fn other() {}",
}),
)
.await;
let (project_a, worktree_id) = client_a.build_local_project(path!("/a"), cx_a).await;
active_call_a
.update(cx_a, |call, cx| call.set_location(Some(&project_a), cx))
.await
.unwrap();
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
// Client B joins the project
let project_b = client_b.join_remote_project(project_id, cx_b).await;
active_call_b
.update(cx_b, |call, cx| call.set_location(Some(&project_b), cx))
.await
.unwrap();
let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a);
// The host opens a rust file.
let _buffer_a = project_a
.update(cx_a, |project, cx| {
project.open_local_buffer(path!("/a/main.rs"), cx)
})
.await
.unwrap();
let editor_a_main = workspace_a
.update_in(cx_a, |workspace, window, cx| {
workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
let fake_language_server = fake_language_servers.next().await.unwrap();
let expected_workspace_diagnostic_token = lsp::ProgressToken::String(format!(
"workspace/diagnostic-{}-1",
fake_language_server.server.server_id()
));
cx_a.run_until_parked();
cx_b.run_until_parked();
let mut pull_diagnostics_handle = pull_diagnostics_handle.lock().take().unwrap();
let mut workspace_diagnostics_pulls_handle =
workspace_diagnostics_pulls_handle.lock().take().unwrap();
if should_stream_workspace_diagnostic {
workspace_diagnostic_received_rx.recv().await.unwrap();
} else {
workspace_diagnostics_pulls_handle.next().await.unwrap();
}
assert_eq!(
1,
workspace_diagnostics_pulls_made.load(atomic::Ordering::Acquire),
"Workspace diagnostics should be pulled initially on a server startup"
);
pull_diagnostics_handle.next().await.unwrap();
assert_eq!(
1,
diagnostics_pulls_made.load(atomic::Ordering::Acquire),
"Host should query pull diagnostics when the editor is opened"
);
executor.run_until_parked();
editor_a_main.update(cx_a, |editor, cx| {
let snapshot = editor.buffer().read(cx).snapshot(cx);
let all_diagnostics = snapshot
.diagnostics_in_range(MultiBufferOffset(0)..snapshot.len())
.collect::<Vec<_>>();
assert_eq!(
all_diagnostics.len(),
1,
"Expected single diagnostic, but got: {all_diagnostics:?}"
);
let diagnostic = &all_diagnostics[0];
let mut expected_messages = vec![expected_pull_diagnostic_main_message];
if !should_stream_workspace_diagnostic {
expected_messages.push(expected_workspace_pull_diagnostics_main_message);
}
assert!(
expected_messages.contains(&diagnostic.diagnostic.message.as_str()),
"Expected {expected_messages:?} on the host, but got: {}",
diagnostic.diagnostic.message
);
});
fake_language_server.notify::<lsp::notification::PublishDiagnostics>(
lsp::PublishDiagnosticsParams {
uri: lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(),
diagnostics: vec![lsp::Diagnostic {
range: lsp::Range {
start: lsp::Position {
line: 0,
character: 3,
},
end: lsp::Position {
line: 0,
character: 4,
},
},
severity: Some(lsp::DiagnosticSeverity::INFORMATION),
message: expected_push_diagnostic_main_message.to_string(),
..lsp::Diagnostic::default()
}],
version: None,
},
);
fake_language_server.notify::<lsp::notification::PublishDiagnostics>(
lsp::PublishDiagnosticsParams {
uri: lsp::Uri::from_file_path(path!("/a/lib.rs")).unwrap(),
diagnostics: vec![lsp::Diagnostic {
range: lsp::Range {
start: lsp::Position {
line: 0,
character: 3,
},
end: lsp::Position {
line: 0,
character: 4,
},
},
severity: Some(lsp::DiagnosticSeverity::INFORMATION),
message: expected_push_diagnostic_lib_message.to_string(),
..lsp::Diagnostic::default()
}],
version: None,
},
);
if should_stream_workspace_diagnostic {
fake_language_server.notify::<lsp::notification::Progress>(lsp::ProgressParams {
token: expected_workspace_diagnostic_token.clone(),
value: lsp::ProgressParamsValue::WorkspaceDiagnostic(
lsp::WorkspaceDiagnosticReportResult::Report(lsp::WorkspaceDiagnosticReport {
items: vec![
lsp::WorkspaceDocumentDiagnosticReport::Full(
lsp::WorkspaceFullDocumentDiagnosticReport {
uri: lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(),
version: None,
full_document_diagnostic_report:
lsp::FullDocumentDiagnosticReport {
result_id: Some(format!(
"workspace_{}",
workspace_diagnostics_pulls_made
.fetch_add(1, atomic::Ordering::Release)
+ 1
)),
items: vec![lsp::Diagnostic {
range: lsp::Range {
start: lsp::Position {
line: 0,
character: 1,
},
end: lsp::Position {
line: 0,
character: 2,
},
},
severity: Some(lsp::DiagnosticSeverity::ERROR),
message:
expected_workspace_pull_diagnostics_main_message
.to_string(),
..lsp::Diagnostic::default()
}],
},
},
),
lsp::WorkspaceDocumentDiagnosticReport::Full(
lsp::WorkspaceFullDocumentDiagnosticReport {
uri: lsp::Uri::from_file_path(path!("/a/lib.rs")).unwrap(),
version: None,
full_document_diagnostic_report:
lsp::FullDocumentDiagnosticReport {
result_id: Some(format!(
"workspace_{}",
workspace_diagnostics_pulls_made
.fetch_add(1, atomic::Ordering::Release)
+ 1
)),
items: Vec::new(),
},
},
),
],
}),
),
});
};
let mut workspace_diagnostic_start_count =
workspace_diagnostics_pulls_made.load(atomic::Ordering::Acquire);
executor.run_until_parked();
editor_a_main.update(cx_a, |editor, cx| {
let snapshot = editor.buffer().read(cx).snapshot(cx);
let all_diagnostics = snapshot
.diagnostics_in_range(MultiBufferOffset(0)..snapshot.len())
.collect::<Vec<_>>();
assert_eq!(
all_diagnostics.len(),
2,
"Expected pull and push diagnostics, but got: {all_diagnostics:?}"
);
let expected_messages = [
expected_workspace_pull_diagnostics_main_message,
expected_pull_diagnostic_main_message,
expected_push_diagnostic_main_message,
];
for diagnostic in all_diagnostics {
assert!(
expected_messages.contains(&diagnostic.diagnostic.message.as_str()),
"Expected push and pull messages on the host: {expected_messages:?}, but got: {}",
diagnostic.diagnostic.message
);
}
});
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
let editor_b_main = workspace_b
.update_in(cx_b, |workspace, window, cx| {
workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
cx_b.run_until_parked();
pull_diagnostics_handle.next().await.unwrap();
assert_eq!(
2,
diagnostics_pulls_made.load(atomic::Ordering::Acquire),
"Client should query pull diagnostics when its editor is opened"
);
executor.run_until_parked();
assert_eq!(
workspace_diagnostic_start_count,
workspace_diagnostics_pulls_made.load(atomic::Ordering::Acquire),
"Workspace diagnostics should not be changed as the remote client does not initialize the workspace diagnostics pull"
);
editor_b_main.update(cx_b, |editor, cx| {
let snapshot = editor.buffer().read(cx).snapshot(cx);
let all_diagnostics = snapshot
.diagnostics_in_range(MultiBufferOffset(0)..snapshot.len())
.collect::<Vec<_>>();
assert_eq!(
all_diagnostics.len(),
2,
"Expected pull and push diagnostics, but got: {all_diagnostics:?}"
);
// Despite the workspace diagnostics not re-initialized for the remote client, we can still expect its message synced from the host.
let expected_messages = [
expected_workspace_pull_diagnostics_main_message,
expected_pull_diagnostic_main_message,
expected_push_diagnostic_main_message,
];
for diagnostic in all_diagnostics {
assert!(
expected_messages.contains(&diagnostic.diagnostic.message.as_str()),
"The client should get both push and pull messages: {expected_messages:?}, but got: {}",
diagnostic.diagnostic.message
);
}
});
let editor_b_lib = workspace_b
.update_in(cx_b, |workspace, window, cx| {
workspace.open_path((worktree_id, rel_path("lib.rs")), None, true, window, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
pull_diagnostics_handle.next().await.unwrap();
assert_eq!(
3,
diagnostics_pulls_made.load(atomic::Ordering::Acquire),
"Client should query pull diagnostics when its another editor is opened"
);
executor.run_until_parked();
assert_eq!(
workspace_diagnostic_start_count,
workspace_diagnostics_pulls_made.load(atomic::Ordering::Acquire),
"The remote client still did not anything to trigger the workspace diagnostics pull"
);
editor_b_lib.update(cx_b, |editor, cx| {
let snapshot = editor.buffer().read(cx).snapshot(cx);
let all_diagnostics = snapshot
.diagnostics_in_range(MultiBufferOffset(0)..snapshot.len())
.collect::<Vec<_>>();
let expected_messages = [
expected_pull_diagnostic_lib_message,
expected_push_diagnostic_lib_message,
];
assert_eq!(
all_diagnostics.len(),
2,
"Expected pull and push diagnostics, but got: {all_diagnostics:?}"
);
for diagnostic in all_diagnostics {
assert!(
expected_messages.contains(&diagnostic.diagnostic.message.as_str()),
"The client should get both push and pull messages: {expected_messages:?}, but got: {}",
diagnostic.diagnostic.message
);
}
});
if should_stream_workspace_diagnostic {
fake_language_server.notify::<lsp::notification::Progress>(lsp::ProgressParams {
token: expected_workspace_diagnostic_token.clone(),
value: lsp::ProgressParamsValue::WorkspaceDiagnostic(
lsp::WorkspaceDiagnosticReportResult::Report(lsp::WorkspaceDiagnosticReport {
items: vec![lsp::WorkspaceDocumentDiagnosticReport::Full(
lsp::WorkspaceFullDocumentDiagnosticReport {
uri: lsp::Uri::from_file_path(path!("/a/lib.rs")).unwrap(),
version: None,
full_document_diagnostic_report: lsp::FullDocumentDiagnosticReport {
result_id: Some(format!(
"workspace_{}",
workspace_diagnostics_pulls_made
.fetch_add(1, atomic::Ordering::Release)
+ 1
)),
items: vec![lsp::Diagnostic {
range: lsp::Range {
start: lsp::Position {
line: 0,
character: 1,
},
end: lsp::Position {
line: 0,
character: 2,
},
},
severity: Some(lsp::DiagnosticSeverity::ERROR),
message: expected_workspace_pull_diagnostics_lib_message
.to_string(),
..lsp::Diagnostic::default()
}],
},
},
)],
}),
),
});
workspace_diagnostic_start_count =
workspace_diagnostics_pulls_made.load(atomic::Ordering::Acquire);
workspace_diagnostic_cancel_tx.send(()).await.unwrap();
workspace_diagnostics_pulls_handle.next().await.unwrap();
executor.run_until_parked();
editor_b_lib.update(cx_b, |editor, cx| {
let snapshot = editor.buffer().read(cx).snapshot(cx);
let all_diagnostics = snapshot
.diagnostics_in_range(MultiBufferOffset(0)..snapshot.len())
.collect::<Vec<_>>();
let expected_messages = [
// Despite workspace diagnostics provided,
// the currently open file's diagnostics should be preferred, as LSP suggests.
expected_pull_diagnostic_lib_message,
expected_push_diagnostic_lib_message,
];
assert_eq!(
all_diagnostics.len(),
2,
"Expected pull and push diagnostics, but got: {all_diagnostics:?}"
);
for diagnostic in all_diagnostics {
assert!(
expected_messages.contains(&diagnostic.diagnostic.message.as_str()),
"The client should get both push and pull messages: {expected_messages:?}, but got: {}",
diagnostic.diagnostic.message
);
}
});
};
{
assert!(
!diagnostics_pulls_result_ids.lock().await.is_empty(),
"Initial diagnostics pulls should report None at least"
);
assert_eq!(
0,
workspace_diagnostics_pulls_result_ids
.lock()
.await
.deref()
.len(),
"After the initial workspace request, opening files should not reuse any result ids"
);
}
editor_b_lib.update_in(cx_b, |editor, window, cx| {
editor.move_to_end(&MoveToEnd, window, cx);
editor.handle_input(":", window, cx);
});
pull_diagnostics_handle.next().await.unwrap();
// pull_diagnostics_handle.next().await.unwrap();
assert_eq!(
4,
diagnostics_pulls_made.load(atomic::Ordering::Acquire),
"Client lib.rs edits should trigger another diagnostics pull for open buffers"
);
workspace_diagnostics_pulls_handle.next().await.unwrap();
assert_eq!(
workspace_diagnostic_start_count + 1,
workspace_diagnostics_pulls_made.load(atomic::Ordering::Acquire),
"After client lib.rs edits, the workspace diagnostics request should follow"
);
executor.run_until_parked();
editor_b_main.update_in(cx_b, |editor, window, cx| {
editor.move_to_end(&MoveToEnd, window, cx);
editor.handle_input(":", window, cx);
});
pull_diagnostics_handle.next().await.unwrap();
pull_diagnostics_handle.next().await.unwrap();
pull_diagnostics_handle.next().await.unwrap();
assert_eq!(
7,
diagnostics_pulls_made.load(atomic::Ordering::Acquire),
"Client main.rs edits should trigger diagnostics pull by both client and host and an extra pull for the client's lib.rs"
);
workspace_diagnostics_pulls_handle.next().await.unwrap();
assert_eq!(
workspace_diagnostic_start_count + 2,
workspace_diagnostics_pulls_made.load(atomic::Ordering::Acquire),
"After client main.rs edits, the workspace diagnostics pull should follow"
);
executor.run_until_parked();
editor_a_main.update_in(cx_a, |editor, window, cx| {
editor.move_to_end(&MoveToEnd, window, cx);
editor.handle_input(":", window, cx);
});
pull_diagnostics_handle.next().await.unwrap();
pull_diagnostics_handle.next().await.unwrap();
pull_diagnostics_handle.next().await.unwrap();
assert_eq!(
10,
diagnostics_pulls_made.load(atomic::Ordering::Acquire),
"Host main.rs edits should trigger another diagnostics pull by both client and host and another pull for the client's lib.rs"
);
workspace_diagnostics_pulls_handle.next().await.unwrap();
assert_eq!(
workspace_diagnostic_start_count + 3,
workspace_diagnostics_pulls_made.load(atomic::Ordering::Acquire),
"After host main.rs edits, the workspace diagnostics pull should follow"
);
executor.run_until_parked();
let diagnostic_pulls_result_ids = diagnostics_pulls_result_ids.lock().await.len();
let workspace_pulls_result_ids = workspace_diagnostics_pulls_result_ids.lock().await.len();
{
assert!(
diagnostic_pulls_result_ids > 1,
"Should have sent result ids when pulling diagnostics"
);
assert!(
workspace_pulls_result_ids > 1,
"Should have sent result ids when pulling workspace diagnostics"
);
}
fake_language_server
.request::<lsp::request::WorkspaceDiagnosticRefresh>((), DEFAULT_LSP_REQUEST_TIMEOUT)
.await
.into_response()
.expect("workspace diagnostics refresh request failed");
// Workspace refresh now also triggers document diagnostic pulls for all open buffers
pull_diagnostics_handle.next().await.unwrap();
pull_diagnostics_handle.next().await.unwrap();
assert_eq!(
12,
diagnostics_pulls_made.load(atomic::Ordering::Acquire),
"Workspace refresh should trigger document pulls for all open buffers (main.rs and lib.rs)"
);
workspace_diagnostics_pulls_handle.next().await.unwrap();
assert_eq!(
workspace_diagnostic_start_count + 4,
workspace_diagnostics_pulls_made.load(atomic::Ordering::Acquire),
"Another workspace diagnostics pull should happen after the diagnostics refresh server request"
);
{
assert!(
diagnostics_pulls_result_ids.lock().await.len() > diagnostic_pulls_result_ids,
"Document diagnostic pulls should happen after workspace refresh"
);
assert!(
workspace_diagnostics_pulls_result_ids.lock().await.len() > workspace_pulls_result_ids,
"More workspace diagnostics should be pulled"
);
}
editor_b_lib.update(cx_b, |editor, cx| {
let snapshot = editor.buffer().read(cx).snapshot(cx);
let all_diagnostics = snapshot
.diagnostics_in_range(MultiBufferOffset(0)..snapshot.len())
.collect::<Vec<_>>();
let expected_messages = [
expected_workspace_pull_diagnostics_lib_message,
expected_pull_diagnostic_lib_message,
expected_push_diagnostic_lib_message,
];
assert_eq!(all_diagnostics.len(), 2);
for diagnostic in &all_diagnostics {
assert!(
expected_messages.contains(&diagnostic.diagnostic.message.as_str()),
"Unexpected diagnostics: {all_diagnostics:?}"
);
}
});
editor_b_main.update(cx_b, |editor, cx| {
let snapshot = editor.buffer().read(cx).snapshot(cx);
let all_diagnostics = snapshot
.diagnostics_in_range(MultiBufferOffset(0)..snapshot.len())
.collect::<Vec<_>>();
assert_eq!(all_diagnostics.len(), 2);
let expected_messages = [
expected_workspace_pull_diagnostics_main_message,
expected_pull_diagnostic_main_message,
expected_push_diagnostic_main_message,
];
for diagnostic in &all_diagnostics {
assert!(
expected_messages.contains(&diagnostic.diagnostic.message.as_str()),
"Unexpected diagnostics: {all_diagnostics:?}"
);
}
});
editor_a_main.update(cx_a, |editor, cx| {
let snapshot = editor.buffer().read(cx).snapshot(cx);
let all_diagnostics = snapshot
.diagnostics_in_range(MultiBufferOffset(0)..snapshot.len())
.collect::<Vec<_>>();
assert_eq!(all_diagnostics.len(), 2);
let expected_messages = [
expected_workspace_pull_diagnostics_main_message,
expected_pull_diagnostic_main_message,
expected_push_diagnostic_main_message,
];
for diagnostic in &all_diagnostics {
assert!(
expected_messages.contains(&diagnostic.diagnostic.message.as_str()),
"Unexpected diagnostics: {all_diagnostics:?}"
);
}
});
}
#[gpui::test(iterations = 10)]
async fn test_non_streamed_lsp_pull_diagnostics(
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
test_lsp_pull_diagnostics(false, cx_a, cx_b).await;
}
#[gpui::test(iterations = 10)]
async fn test_streamed_lsp_pull_diagnostics(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
test_lsp_pull_diagnostics(true, cx_a, cx_b).await;
}
#[gpui::test(iterations = 10)]
async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
let mut server = TestServer::start(cx_a.executor()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
let active_call_a = cx_a.read(ActiveCall::global);
cx_a.update(editor::init);
cx_b.update(editor::init);
// Turn inline-blame-off by default so no state is transferred without us explicitly doing so
let inline_blame_off_settings = Some(InlineBlameSettings {
enabled: Some(false),
..Default::default()
});
cx_a.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings.git.get_or_insert_default().inline_blame = inline_blame_off_settings;
});
});
});
cx_b.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings.git.get_or_insert_default().inline_blame = inline_blame_off_settings;
});
});
});
client_a
.fs()
.insert_tree(
path!("/my-repo"),
json!({
".git": {},
"file.txt": "line1\nline2\nline3\nline\n",
}),
)
.await;
let blame = git::blame::Blame {
entries: vec![
blame_entry("1b1b1b", 0..1),
blame_entry("0d0d0d", 1..2),
blame_entry("3a3a3a", 2..3),
blame_entry("4c4c4c", 3..4),
],
messages: [
("1b1b1b", "message for idx-0"),
("0d0d0d", "message for idx-1"),
("3a3a3a", "message for idx-2"),
("4c4c4c", "message for idx-3"),
]
.into_iter()
.map(|(sha, message)| (sha.parse().unwrap(), message.into()))
.collect(),
tag_names: Default::default(),
};
client_a.fs().set_blame_for_repo(
Path::new(path!("/my-repo/.git")),
vec![(repo_path("file.txt"), blame)],
);
let (project_a, worktree_id) = client_a.build_local_project(path!("/my-repo"), cx_a).await;
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
// Create editor_a
let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a);
let editor_a = workspace_a
.update_in(cx_a, |workspace, window, cx| {
workspace.open_path((worktree_id, rel_path("file.txt")), None, true, window, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
// Join the project as client B.
let project_b = client_b.join_remote_project(project_id, cx_b).await;
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
let editor_b = workspace_b
.update_in(cx_b, |workspace, window, cx| {
workspace.open_path((worktree_id, rel_path("file.txt")), None, true, window, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
let buffer_id_b = editor_b.update(cx_b, |editor_b, cx| {
editor_b
.buffer()
.read(cx)
.as_singleton()
.unwrap()
.read(cx)
.remote_id()
});
// client_b now requests git blame for the open buffer
editor_b.update_in(cx_b, |editor_b, window, cx| {
assert!(editor_b.blame().is_none());
editor_b.toggle_git_blame(&git::Blame {}, window, cx);
});
cx_a.executor().run_until_parked();
cx_b.executor().run_until_parked();
editor_b.update(cx_b, |editor_b, cx| {
let blame = editor_b.blame().expect("editor_b should have blame now");
let entries = blame.update(cx, |blame, cx| {
blame
.blame_for_rows(
&(0..4)
.map(|row| RowInfo {
buffer_row: Some(row),
buffer_id: Some(buffer_id_b),
..Default::default()
})
.collect::<Vec<_>>(),
cx,
)
.collect::<Vec<_>>()
});
assert_eq!(
entries,
vec![
Some((buffer_id_b, blame_entry("1b1b1b", 0..1))),
Some((buffer_id_b, blame_entry("0d0d0d", 1..2))),
Some((buffer_id_b, blame_entry("3a3a3a", 2..3))),
Some((buffer_id_b, blame_entry("4c4c4c", 3..4))),
]
);
blame.update(cx, |blame, _| {
for (idx, (buffer, entry)) in entries.iter().flatten().enumerate() {
let details = blame.details_for_entry(*buffer, entry).unwrap();
assert_eq!(details.message, format!("message for idx-{}", idx));
}
});
});
// editor_b updates the file, which gets sent to client_a, which updates git blame,
// which gets back to client_b.
editor_b.update_in(cx_b, |editor_b, _, cx| {
editor_b.edit([(Point::new(0, 3)..Point::new(0, 3), "FOO")], cx);
});
cx_a.executor().run_until_parked();
cx_b.executor().run_until_parked();
editor_b.update(cx_b, |editor_b, cx| {
let blame = editor_b.blame().expect("editor_b should have blame now");
let entries = blame.update(cx, |blame, cx| {
blame
.blame_for_rows(
&(0..4)
.map(|row| RowInfo {
buffer_row: Some(row),
buffer_id: Some(buffer_id_b),
..Default::default()
})
.collect::<Vec<_>>(),
cx,
)
.collect::<Vec<_>>()
});
assert_eq!(
entries,
vec![
None,
Some((buffer_id_b, blame_entry("0d0d0d", 1..2))),
Some((buffer_id_b, blame_entry("3a3a3a", 2..3))),
Some((buffer_id_b, blame_entry("4c4c4c", 3..4))),
]
);
});
// Now editor_a also updates the file
editor_a.update_in(cx_a, |editor_a, _, cx| {
editor_a.edit([(Point::new(1, 3)..Point::new(1, 3), "FOO")], cx);
});
cx_a.executor().run_until_parked();
cx_b.executor().run_until_parked();
editor_b.update(cx_b, |editor_b, cx| {
let blame = editor_b.blame().expect("editor_b should have blame now");
let entries = blame.update(cx, |blame, cx| {
blame
.blame_for_rows(
&(0..4)
.map(|row| RowInfo {
buffer_row: Some(row),
buffer_id: Some(buffer_id_b),
..Default::default()
})
.collect::<Vec<_>>(),
cx,
)
.collect::<Vec<_>>()
});
assert_eq!(
entries,
vec![
None,
None,
Some((buffer_id_b, blame_entry("3a3a3a", 2..3))),
Some((buffer_id_b, blame_entry("4c4c4c", 3..4))),
]
);
});
}
#[gpui::test(iterations = 30)]
async fn test_collaborating_with_editorconfig(
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
let mut server = TestServer::start(cx_a.executor()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
let active_call_a = cx_a.read(ActiveCall::global);
cx_b.update(editor::init);
// Set up a fake language server.
client_a.language_registry().add(rust_lang());
client_a
.fs()
.insert_tree(
path!("/a"),
json!({
"src": {
"main.rs": "mod other;\nfn main() { let foo = other::foo(); }",
"other_mod": {
"other.rs": "pub fn foo() -> usize {\n 4\n}",
".editorconfig": "",
},
},
".editorconfig": "[*]\ntab_width = 2\n",
}),
)
.await;
let (project_a, worktree_id) = client_a.build_local_project(path!("/a"), cx_a).await;
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
let main_buffer_a = project_a
.update(cx_a, |p, cx| {
p.open_buffer((worktree_id, rel_path("src/main.rs")), cx)
})
.await
.unwrap();
let other_buffer_a = project_a
.update(cx_a, |p, cx| {
p.open_buffer((worktree_id, rel_path("src/other_mod/other.rs")), cx)
})
.await
.unwrap();
let cx_a = cx_a.add_empty_window();
let main_editor_a = cx_a.new_window_entity(|window, cx| {
Editor::for_buffer(main_buffer_a, Some(project_a.clone()), window, cx)
});
let other_editor_a = cx_a.new_window_entity(|window, cx| {
Editor::for_buffer(other_buffer_a, Some(project_a), window, cx)
});
let mut main_editor_cx_a = EditorTestContext {
cx: cx_a.clone(),
window: cx_a.window_handle(),
editor: main_editor_a,
assertion_cx: AssertionContextManager::new(),
};
let mut other_editor_cx_a = EditorTestContext {
cx: cx_a.clone(),
window: cx_a.window_handle(),
editor: other_editor_a,
assertion_cx: AssertionContextManager::new(),
};
// Join the project as client B.
let project_b = client_b.join_remote_project(project_id, cx_b).await;
let main_buffer_b = project_b
.update(cx_b, |p, cx| {
p.open_buffer((worktree_id, rel_path("src/main.rs")), cx)
})
.await
.unwrap();
let other_buffer_b = project_b
.update(cx_b, |p, cx| {
p.open_buffer((worktree_id, rel_path("src/other_mod/other.rs")), cx)
})
.await
.unwrap();
let cx_b = cx_b.add_empty_window();
let main_editor_b = cx_b.new_window_entity(|window, cx| {
Editor::for_buffer(main_buffer_b, Some(project_b.clone()), window, cx)
});
let other_editor_b = cx_b.new_window_entity(|window, cx| {
Editor::for_buffer(other_buffer_b, Some(project_b.clone()), window, cx)
});
let mut main_editor_cx_b = EditorTestContext {
cx: cx_b.clone(),
window: cx_b.window_handle(),
editor: main_editor_b,
assertion_cx: AssertionContextManager::new(),
};
let mut other_editor_cx_b = EditorTestContext {
cx: cx_b.clone(),
window: cx_b.window_handle(),
editor: other_editor_b,
assertion_cx: AssertionContextManager::new(),
};
let initial_main = indoc! {"
ˇmod other;
fn main() { let foo = other::foo(); }"};
let initial_other = indoc! {"
ˇpub fn foo() -> usize {
4
}"};
let first_tabbed_main = indoc! {"
ˇmod other;
fn main() { let foo = other::foo(); }"};
tab_undo_assert(
&mut main_editor_cx_a,
&mut main_editor_cx_b,
initial_main,
first_tabbed_main,
true,
);
tab_undo_assert(
&mut main_editor_cx_a,
&mut main_editor_cx_b,
initial_main,
first_tabbed_main,
false,
);
let first_tabbed_other = indoc! {"
ˇpub fn foo() -> usize {
4
}"};
tab_undo_assert(
&mut other_editor_cx_a,
&mut other_editor_cx_b,
initial_other,
first_tabbed_other,
true,
);
tab_undo_assert(
&mut other_editor_cx_a,
&mut other_editor_cx_b,
initial_other,
first_tabbed_other,
false,
);
client_a
.fs()
.atomic_write(
PathBuf::from(path!("/a/src/.editorconfig")),
"[*]\ntab_width = 3\n".to_owned(),
)
.await
.unwrap();
cx_a.run_until_parked();
cx_b.run_until_parked();
let second_tabbed_main = indoc! {"
ˇmod other;
fn main() { let foo = other::foo(); }"};
tab_undo_assert(
&mut main_editor_cx_a,
&mut main_editor_cx_b,
initial_main,
second_tabbed_main,
true,
);
tab_undo_assert(
&mut main_editor_cx_a,
&mut main_editor_cx_b,
initial_main,
second_tabbed_main,
false,
);
let second_tabbed_other = indoc! {"
ˇpub fn foo() -> usize {
4
}"};
tab_undo_assert(
&mut other_editor_cx_a,
&mut other_editor_cx_b,
initial_other,
second_tabbed_other,
true,
);
tab_undo_assert(
&mut other_editor_cx_a,
&mut other_editor_cx_b,
initial_other,
second_tabbed_other,
false,
);
let editorconfig_buffer_b = project_b
.update(cx_b, |p, cx| {
p.open_buffer((worktree_id, rel_path("src/other_mod/.editorconfig")), cx)
})
.await
.unwrap();
editorconfig_buffer_b.update(cx_b, |buffer, cx| {
buffer.set_text("[*.rs]\ntab_width = 6\n", cx);
});
project_b
.update(cx_b, |project, cx| {
project.save_buffer(editorconfig_buffer_b.clone(), cx)
})
.await
.unwrap();
cx_a.run_until_parked();
cx_b.run_until_parked();
tab_undo_assert(
&mut main_editor_cx_a,
&mut main_editor_cx_b,
initial_main,
second_tabbed_main,
true,
);
tab_undo_assert(
&mut main_editor_cx_a,
&mut main_editor_cx_b,
initial_main,
second_tabbed_main,
false,
);
let third_tabbed_other = indoc! {"
ˇpub fn foo() -> usize {
4
}"};
tab_undo_assert(
&mut other_editor_cx_a,
&mut other_editor_cx_b,
initial_other,
third_tabbed_other,
true,
);
tab_undo_assert(
&mut other_editor_cx_a,
&mut other_editor_cx_b,
initial_other,
third_tabbed_other,
false,
);
}
#[gpui::test(iterations = 10)]
async fn test_collaborating_with_external_editorconfig(
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
let mut server = TestServer::start(cx_a.executor()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
let active_call_a = cx_a.read(ActiveCall::global);
client_a.language_registry().add(rust_lang());
client_b.language_registry().add(rust_lang());
// Set up external .editorconfig in parent directory
client_a
.fs()
.insert_tree(
path!("/parent"),
json!({
".editorconfig": "[*]\nindent_size = 5\n",
"worktree": {
".editorconfig": "[*]\n",
"src": {
"main.rs": "fn main() {}",
},
},
}),
)
.await;
let (project_a, worktree_id) = client_a
.build_local_project(path!("/parent/worktree"), cx_a)
.await;
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
project_a.update(cx_a, |project, _| project.languages().add(rust_lang()));
// Open buffer on client A
let buffer_a = project_a
.update(cx_a, |p, cx| {
p.open_buffer((worktree_id, rel_path("src/main.rs")), cx)
})
.await
.unwrap();
cx_a.run_until_parked();
// Verify client A sees external editorconfig settings
cx_a.read(|cx| {
let settings = LanguageSettings::for_buffer(&buffer_a.read(cx), cx);
assert_eq!(Some(settings.tab_size), NonZeroU32::new(5));
});
// Client B joins the project
let project_b = client_b.join_remote_project(project_id, cx_b).await;
project_b.update(cx_b, |project, _| project.languages().add(rust_lang()));
let buffer_b = project_b
.update(cx_b, |p, cx| {
p.open_buffer((worktree_id, rel_path("src/main.rs")), cx)
})
.await
.unwrap();
cx_b.run_until_parked();
// Verify client B also sees external editorconfig settings
cx_b.read(|cx| {
let settings = LanguageSettings::for_buffer(&buffer_b.read(cx), cx);
assert_eq!(Some(settings.tab_size), NonZeroU32::new(5));
});
// Client A modifies the external .editorconfig
client_a
.fs()
.atomic_write(
PathBuf::from(path!("/parent/.editorconfig")),
"[*]\nindent_size = 9\n".to_owned(),
)
.await
.unwrap();
cx_a.run_until_parked();
cx_b.run_until_parked();
// Verify client A sees updated settings
cx_a.read(|cx| {
let settings = LanguageSettings::for_buffer(&buffer_a.read(cx), cx);
assert_eq!(Some(settings.tab_size), NonZeroU32::new(9));
});
// Verify client B also sees updated settings
cx_b.read(|cx| {
let settings = LanguageSettings::for_buffer(&buffer_b.read(cx), cx);
assert_eq!(Some(settings.tab_size), NonZeroU32::new(9));
});
}
#[gpui::test]
async fn test_add_breakpoints(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
let executor = cx_a.executor();
let mut server = TestServer::start(executor.clone()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
let active_call_a = cx_a.read(ActiveCall::global);
let active_call_b = cx_b.read(ActiveCall::global);
cx_a.update(editor::init);
cx_b.update(editor::init);
client_a
.fs()
.insert_tree(
"/a",
json!({
"test.txt": "one\ntwo\nthree\nfour\nfive",
}),
)
.await;
let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await;
let project_path = ProjectPath {
worktree_id,
path: rel_path(&"test.txt").into(),
};
let abs_path = project_a.read_with(cx_a, |project, cx| {
project
.absolute_path(&project_path, cx)
.map(Arc::from)
.unwrap()
});
active_call_a
.update(cx_a, |call, cx| call.set_location(Some(&project_a), cx))
.await
.unwrap();
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
let project_b = client_b.join_remote_project(project_id, cx_b).await;
active_call_b
.update(cx_b, |call, cx| call.set_location(Some(&project_b), cx))
.await
.unwrap();
let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a);
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
// Client A opens an editor.
let editor_a = workspace_a
.update_in(cx_a, |workspace, window, cx| {
workspace.open_path(project_path.clone(), None, true, window, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
// Client B opens same editor as A.
let editor_b = workspace_b
.update_in(cx_b, |workspace, window, cx| {
workspace.open_path(project_path.clone(), None, true, window, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
cx_a.run_until_parked();
cx_b.run_until_parked();
// Client A adds breakpoint on line (1)
editor_a.update_in(cx_a, |editor, window, cx| {
editor.toggle_breakpoint(&editor::actions::ToggleBreakpoint, window, cx);
});
cx_a.run_until_parked();
cx_b.run_until_parked();
let breakpoints_a = editor_a.update(cx_a, |editor, cx| {
editor
.breakpoint_store()
.unwrap()
.read(cx)
.all_source_breakpoints(cx)
});
let breakpoints_b = editor_b.update(cx_b, |editor, cx| {
editor
.breakpoint_store()
.unwrap()
.read(cx)
.all_source_breakpoints(cx)
});
assert_eq!(1, breakpoints_a.len());
assert_eq!(1, breakpoints_a.get(&abs_path).unwrap().len());
assert_eq!(breakpoints_a, breakpoints_b);
// Client B adds breakpoint on line(2)
editor_b.update_in(cx_b, |editor, window, cx| {
editor.move_down(&zed_actions::editor::MoveDown, window, cx);
editor.move_down(&zed_actions::editor::MoveDown, window, cx);
editor.toggle_breakpoint(&editor::actions::ToggleBreakpoint, window, cx);
});
cx_a.run_until_parked();
cx_b.run_until_parked();
let breakpoints_a = editor_a.update(cx_a, |editor, cx| {
editor
.breakpoint_store()
.unwrap()
.read(cx)
.all_source_breakpoints(cx)
});
let breakpoints_b = editor_b.update(cx_b, |editor, cx| {
editor
.breakpoint_store()
.unwrap()
.read(cx)
.all_source_breakpoints(cx)
});
assert_eq!(1, breakpoints_a.len());
assert_eq!(breakpoints_a, breakpoints_b);
assert_eq!(2, breakpoints_a.get(&abs_path).unwrap().len());
// Client A removes last added breakpoint from client B
editor_a.update_in(cx_a, |editor, window, cx| {
editor.move_down(&zed_actions::editor::MoveDown, window, cx);
editor.move_down(&zed_actions::editor::MoveDown, window, cx);
editor.toggle_breakpoint(&editor::actions::ToggleBreakpoint, window, cx);
});
cx_a.run_until_parked();
cx_b.run_until_parked();
let breakpoints_a = editor_a.update(cx_a, |editor, cx| {
editor
.breakpoint_store()
.unwrap()
.read(cx)
.all_source_breakpoints(cx)
});
let breakpoints_b = editor_b.update(cx_b, |editor, cx| {
editor
.breakpoint_store()
.unwrap()
.read(cx)
.all_source_breakpoints(cx)
});
assert_eq!(1, breakpoints_a.len());
assert_eq!(breakpoints_a, breakpoints_b);
assert_eq!(1, breakpoints_a.get(&abs_path).unwrap().len());
// Client B removes first added breakpoint by client A
editor_b.update_in(cx_b, |editor, window, cx| {
editor.move_up(&zed_actions::editor::MoveUp, window, cx);
editor.move_up(&zed_actions::editor::MoveUp, window, cx);
editor.toggle_breakpoint(&editor::actions::ToggleBreakpoint, window, cx);
});
cx_a.run_until_parked();
cx_b.run_until_parked();
let breakpoints_a = editor_a.update(cx_a, |editor, cx| {
editor
.breakpoint_store()
.unwrap()
.read(cx)
.all_source_breakpoints(cx)
});
let breakpoints_b = editor_b.update(cx_b, |editor, cx| {
editor
.breakpoint_store()
.unwrap()
.read(cx)
.all_source_breakpoints(cx)
});
assert_eq!(0, breakpoints_a.len());
assert_eq!(breakpoints_a, breakpoints_b);
}
#[gpui::test]
async fn test_client_can_query_lsp_ext(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
let mut server = TestServer::start(cx_a.executor()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
let active_call_a = cx_a.read(ActiveCall::global);
let active_call_b = cx_b.read(ActiveCall::global);
cx_a.update(editor::init);
cx_b.update(editor::init);
client_a.language_registry().add(rust_lang());
let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
"Rust",
FakeLspAdapter {
name: "rust-analyzer",
..FakeLspAdapter::default()
},
);
client_b.language_registry().add(rust_lang());
client_b.language_registry().register_fake_lsp_adapter(
"Rust",
FakeLspAdapter {
name: "rust-analyzer",
..FakeLspAdapter::default()
},
);
client_a
.fs()
.insert_tree(
path!("/a"),
json!({
"main.rs": "fn main() {}",
}),
)
.await;
let (project_a, worktree_id) = client_a.build_local_project(path!("/a"), cx_a).await;
active_call_a
.update(cx_a, |call, cx| call.set_location(Some(&project_a), cx))
.await
.unwrap();
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
let project_b = client_b.join_remote_project(project_id, cx_b).await;
active_call_b
.update(cx_b, |call, cx| call.set_location(Some(&project_b), cx))
.await
.unwrap();
let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a);
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
let editor_a = workspace_a
.update_in(cx_a, |workspace, window, cx| {
workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
let editor_b = workspace_b
.update_in(cx_b, |workspace, window, cx| {
workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
let fake_language_server = fake_language_servers.next().await.unwrap();
// host
let mut expand_request_a = fake_language_server.set_request_handler::<LspExtExpandMacro, _, _>(
|params, _| async move {
assert_eq!(
params.text_document.uri,
lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(),
);
assert_eq!(params.position, lsp::Position::new(0, 0));
Ok(Some(ExpandedMacro {
name: "test_macro_name".to_string(),
expansion: "test_macro_expansion on the host".to_string(),
}))
},
);
editor_a.update_in(cx_a, |editor, window, cx| {
expand_macro_recursively(editor, &ExpandMacroRecursively, window, cx)
});
expand_request_a.next().await.unwrap();
cx_a.run_until_parked();
workspace_a.update(cx_a, |workspace, cx| {
workspace.active_pane().update(cx, |pane, cx| {
assert_eq!(
pane.items_len(),
2,
"Should have added a macro expansion to the host's pane"
);
let new_editor = pane.active_item().unwrap().downcast::<Editor>().unwrap();
new_editor.update(cx, |editor, cx| {
assert_eq!(editor.text(cx), "test_macro_expansion on the host");
});
})
});
// client
let mut expand_request_b = fake_language_server.set_request_handler::<LspExtExpandMacro, _, _>(
|params, _| async move {
assert_eq!(
params.text_document.uri,
lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(),
);
assert_eq!(
params.position,
lsp::Position::new(0, 12),
"editor_b has selected the entire text and should query for a different position"
);
Ok(Some(ExpandedMacro {
name: "test_macro_name".to_string(),
expansion: "test_macro_expansion on the client".to_string(),
}))
},
);
editor_b.update_in(cx_b, |editor, window, cx| {
editor.select_all(&SelectAll, window, cx);
expand_macro_recursively(editor, &ExpandMacroRecursively, window, cx)
});
expand_request_b.next().await.unwrap();
cx_b.run_until_parked();
workspace_b.update(cx_b, |workspace, cx| {
workspace.active_pane().update(cx, |pane, cx| {
assert_eq!(
pane.items_len(),
2,
"Should have added a macro expansion to the client's pane"
);
let new_editor = pane.active_item().unwrap().downcast::<Editor>().unwrap();
new_editor.update(cx, |editor, cx| {
assert_eq!(editor.text(cx), "test_macro_expansion on the client");
});
})
});
}
#[gpui::test]
async fn test_copy_file_name_without_extension(
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
let mut server = TestServer::start(cx_a.executor()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
cx_b.update(editor::init);
client_a
.fs()
.insert_tree(
path!("/root"),
json!({
"src": {
"main.rs": indoc! {"
fn main() {
println!(\"Hello, world!\");
}
"},
}
}),
)
.await;
let (project_a, worktree_id) = client_a.build_local_project(path!("/root"), cx_a).await;
let active_call_a = cx_a.read(ActiveCall::global);
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
let project_b = client_b.join_remote_project(project_id, cx_b).await;
let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a);
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
let editor_a = workspace_a
.update_in(cx_a, |workspace, window, cx| {
workspace.open_path(
(worktree_id, rel_path("src/main.rs")),
None,
true,
window,
cx,
)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
let editor_b = workspace_b
.update_in(cx_b, |workspace, window, cx| {
workspace.open_path(
(worktree_id, rel_path("src/main.rs")),
None,
true,
window,
cx,
)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
cx_a.run_until_parked();
cx_b.run_until_parked();
editor_a.update_in(cx_a, |editor, window, cx| {
editor.copy_file_name_without_extension(&CopyFileNameWithoutExtension, window, cx);
});
assert_eq!(
cx_a.read_from_clipboard().and_then(|item| item.text()),
Some("main".to_string())
);
editor_b.update_in(cx_b, |editor, window, cx| {
editor.copy_file_name_without_extension(&CopyFileNameWithoutExtension, window, cx);
});
assert_eq!(
cx_b.read_from_clipboard().and_then(|item| item.text()),
Some("main".to_string())
);
}
#[gpui::test]
async fn test_copy_file_name(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
let mut server = TestServer::start(cx_a.executor()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
cx_b.update(editor::init);
client_a
.fs()
.insert_tree(
path!("/root"),
json!({
"src": {
"main.rs": indoc! {"
fn main() {
println!(\"Hello, world!\");
}
"},
}
}),
)
.await;
let (project_a, worktree_id) = client_a.build_local_project(path!("/root"), cx_a).await;
let active_call_a = cx_a.read(ActiveCall::global);
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
let project_b = client_b.join_remote_project(project_id, cx_b).await;
let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a);
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
let editor_a = workspace_a
.update_in(cx_a, |workspace, window, cx| {
workspace.open_path(
(worktree_id, rel_path("src/main.rs")),
None,
true,
window,
cx,
)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
let editor_b = workspace_b
.update_in(cx_b, |workspace, window, cx| {
workspace.open_path(
(worktree_id, rel_path("src/main.rs")),
None,
true,
window,
cx,
)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
cx_a.run_until_parked();
cx_b.run_until_parked();
editor_a.update_in(cx_a, |editor, window, cx| {
editor.copy_file_name(&CopyFileName, window, cx);
});
assert_eq!(
cx_a.read_from_clipboard().and_then(|item| item.text()),
Some("main.rs".to_string())
);
editor_b.update_in(cx_b, |editor, window, cx| {
editor.copy_file_name(&CopyFileName, window, cx);
});
assert_eq!(
cx_b.read_from_clipboard().and_then(|item| item.text()),
Some("main.rs".to_string())
);
}
#[gpui::test]
async fn test_copy_file_location(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
let mut server = TestServer::start(cx_a.executor()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
cx_b.update(editor::init);
client_a
.fs()
.insert_tree(
path!("/root"),
json!({
"src": {
"main.rs": indoc! {"
fn main() {
println!(\"Hello, world!\");
}
"},
}
}),
)
.await;
let (project_a, worktree_id) = client_a.build_local_project(path!("/root"), cx_a).await;
let active_call_a = cx_a.read(ActiveCall::global);
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
let project_b = client_b.join_remote_project(project_id, cx_b).await;
let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a);
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
let editor_a = workspace_a
.update_in(cx_a, |workspace, window, cx| {
workspace.open_path(
(worktree_id, rel_path("src/main.rs")),
None,
true,
window,
cx,
)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
let editor_b = workspace_b
.update_in(cx_b, |workspace, window, cx| {
workspace.open_path(
(worktree_id, rel_path("src/main.rs")),
None,
true,
window,
cx,
)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
cx_a.run_until_parked();
cx_b.run_until_parked();
editor_a.update_in(cx_a, |editor, window, cx| {
editor.change_selections(Default::default(), window, cx, |s| {
s.select_ranges([MultiBufferOffset(16)..MultiBufferOffset(16)]);
});
editor.copy_file_location(&CopyFileLocation, window, cx);
});
assert_eq!(
cx_a.read_from_clipboard().and_then(|item| item.text()),
Some(format!("{}:2", path!("src/main.rs")))
);
editor_b.update_in(cx_b, |editor, window, cx| {
editor.change_selections(Default::default(), window, cx, |s| {
s.select_ranges([MultiBufferOffset(16)..MultiBufferOffset(16)]);
});
editor.copy_file_location(&CopyFileLocation, window, cx);
});
assert_eq!(
cx_b.read_from_clipboard().and_then(|item| item.text()),
Some(format!("{}:2", path!("src/main.rs")))
);
editor_a.update_in(cx_a, |editor, window, cx| {
editor.change_selections(Default::default(), window, cx, |s| {
s.select_ranges([MultiBufferOffset(16)..MultiBufferOffset(44)]);
});
editor.copy_file_location(&CopyFileLocation, window, cx);
});
assert_eq!(
cx_a.read_from_clipboard().and_then(|item| item.text()),
Some(format!("{}:2-3", path!("src/main.rs")))
);
editor_b.update_in(cx_b, |editor, window, cx| {
editor.change_selections(Default::default(), window, cx, |s| {
s.select_ranges([MultiBufferOffset(16)..MultiBufferOffset(44)]);
});
editor.copy_file_location(&CopyFileLocation, window, cx);
});
assert_eq!(
cx_b.read_from_clipboard().and_then(|item| item.text()),
Some(format!("{}:2-3", path!("src/main.rs")))
);
editor_a.update_in(cx_a, |editor, window, cx| {
editor.change_selections(Default::default(), window, cx, |s| {
s.select_ranges([MultiBufferOffset(16)..MultiBufferOffset(43)]);
});
editor.copy_file_location(&CopyFileLocation, window, cx);
});
assert_eq!(
cx_a.read_from_clipboard().and_then(|item| item.text()),
Some(format!("{}:2", path!("src/main.rs")))
);
editor_b.update_in(cx_b, |editor, window, cx| {
editor.change_selections(Default::default(), window, cx, |s| {
s.select_ranges([MultiBufferOffset(16)..MultiBufferOffset(43)]);
});
editor.copy_file_location(&CopyFileLocation, window, cx);
});
assert_eq!(
cx_b.read_from_clipboard().and_then(|item| item.text()),
Some(format!("{}:2", path!("src/main.rs")))
);
}
#[track_caller]
fn tab_undo_assert(
cx_a: &mut EditorTestContext,
cx_b: &mut EditorTestContext,
expected_initial: &str,
expected_tabbed: &str,
a_tabs: bool,
) {
cx_a.assert_editor_state(expected_initial);
cx_b.assert_editor_state(expected_initial);
if a_tabs {
cx_a.update_editor(|editor, window, cx| {
editor.tab(&editor::actions::Tab, window, cx);
});
} else {
cx_b.update_editor(|editor, window, cx| {
editor.tab(&editor::actions::Tab, window, cx);
});
}
cx_a.run_until_parked();
cx_b.run_until_parked();
cx_a.assert_editor_state(expected_tabbed);
cx_b.assert_editor_state(expected_tabbed);
if a_tabs {
cx_a.update_editor(|editor, window, cx| {
editor.undo(&editor::actions::Undo, window, cx);
});
} else {
cx_b.update_editor(|editor, window, cx| {
editor.undo(&editor::actions::Undo, window, cx);
});
}
cx_a.run_until_parked();
cx_b.run_until_parked();
cx_a.assert_editor_state(expected_initial);
cx_b.assert_editor_state(expected_initial);
}
fn extract_hint_labels(editor: &Editor, cx: &mut App) -> Vec<String> {
let lsp_store = editor.project().unwrap().read(cx).lsp_store();
let mut all_cached_labels = Vec::new();
let mut all_fetched_hints = Vec::new();
for buffer in editor.buffer().read(cx).all_buffers() {
lsp_store.update(cx, |lsp_store, cx| {
let hints = &lsp_store.latest_lsp_data(&buffer, cx).inlay_hints();
all_cached_labels.extend(hints.all_cached_hints().into_iter().map(|hint| {
let mut label = hint.text().to_string();
if hint.padding_left {
label.insert(0, ' ');
}
if hint.padding_right {
label.push_str(" ");
}
label
}));
all_fetched_hints.extend(hints.all_fetched_hints());
});
}
assert!(
all_fetched_hints.is_empty(),
"Did not expect background hints fetch tasks, but got {} of them",
all_fetched_hints.len()
);
all_cached_labels
}
#[track_caller]
fn extract_color_inlays(editor: &Editor, cx: &App) -> Vec<Rgba> {
editor
.all_inlays(cx)
.into_iter()
.filter_map(|inlay| inlay.get_color())
.map(Rgba::from)
.collect()
}
fn extract_semantic_token_ranges(editor: &Editor, cx: &App) -> Vec<Range<MultiBufferOffset>> {
let multi_buffer_snapshot = editor.buffer().read(cx).snapshot(cx);
editor
.display_map
.read(cx)
.semantic_token_highlights
.iter()
.flat_map(|(_, (v, _))| v.iter())
.map(|highlights| highlights.range.to_offset(&multi_buffer_snapshot))
.collect()
}
#[gpui::test(iterations = 10)]
async fn test_mutual_editor_semantic_token_cache_update(
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
let mut server = TestServer::start(cx_a.executor()).await;
let executor = cx_a.executor();
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
let active_call_a = cx_a.read(ActiveCall::global);
let active_call_b = cx_b.read(ActiveCall::global);
cx_a.update(editor::init);
cx_b.update(editor::init);
cx_a.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings.project.all_languages.defaults.semantic_tokens =
Some(SemanticTokens::Full);
});
});
});
cx_b.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings.project.all_languages.defaults.semantic_tokens =
Some(SemanticTokens::Full);
});
});
});
let capabilities = lsp::ServerCapabilities {
semantic_tokens_provider: Some(
lsp::SemanticTokensServerCapabilities::SemanticTokensOptions(
lsp::SemanticTokensOptions {
legend: lsp::SemanticTokensLegend {
token_types: vec!["function".into()],
token_modifiers: vec![],
},
full: Some(lsp::SemanticTokensFullOptions::Delta { delta: None }),
..Default::default()
},
),
),
..lsp::ServerCapabilities::default()
};
client_a.language_registry().add(rust_lang());
let edits_made = Arc::new(AtomicUsize::new(0));
let closure_edits_made = Arc::clone(&edits_made);
let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
"Rust",
FakeLspAdapter {
capabilities: capabilities.clone(),
initializer: Some(Box::new(move |fake_language_server| {
let closure_edits_made = closure_edits_made.clone();
fake_language_server
.set_request_handler::<lsp::request::SemanticTokensFullRequest, _, _>(
move |_, _| {
let edits_made_2 = Arc::clone(&closure_edits_made);
async move {
let edits_made =
AtomicUsize::load(&edits_made_2, atomic::Ordering::Acquire);
Ok(Some(lsp::SemanticTokensResult::Tokens(
lsp::SemanticTokens {
data: vec![
0, // delta_line
3, // delta_start
edits_made as u32 + 4, // length
0, // token_type
0, // token_modifiers_bitset
],
result_id: None,
},
)))
}
},
);
})),
..FakeLspAdapter::default()
},
);
client_b.language_registry().add(rust_lang());
client_b.language_registry().register_fake_lsp_adapter(
"Rust",
FakeLspAdapter {
capabilities,
..FakeLspAdapter::default()
},
);
client_a
.fs()
.insert_tree(
path!("/a"),
json!({
"main.rs": "fn main() { a }",
"other.rs": "// Test file",
}),
)
.await;
let (project_a, worktree_id) = client_a.build_local_project(path!("/a"), cx_a).await;
active_call_a
.update(cx_a, |call, cx| call.set_location(Some(&project_a), cx))
.await
.unwrap();
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
let project_b = client_b.join_remote_project(project_id, cx_b).await;
active_call_b
.update(cx_b, |call, cx| call.set_location(Some(&project_b), cx))
.await
.unwrap();
let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a);
let file_a = workspace_a.update_in(cx_a, |workspace, window, cx| {
workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx)
});
let _fake_language_server = fake_language_servers.next().await.unwrap();
let editor_a = file_a.await.unwrap().downcast::<Editor>().unwrap();
executor.advance_clock(Duration::from_millis(100));
executor.run_until_parked();
let initial_edit = edits_made.load(atomic::Ordering::Acquire);
editor_a.update(cx_a, |editor, cx| {
let ranges = extract_semantic_token_ranges(editor, cx);
assert_eq!(
ranges,
vec![MultiBufferOffset(3)..MultiBufferOffset(3 + initial_edit + 4)],
"Host should get its first semantic tokens when opening an editor"
);
});
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
let editor_b = workspace_b
.update_in(cx_b, |workspace, window, cx| {
workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
executor.advance_clock(Duration::from_millis(100));
executor.run_until_parked();
editor_b.update(cx_b, |editor, cx| {
let ranges = extract_semantic_token_ranges(editor, cx);
assert_eq!(
ranges,
vec![MultiBufferOffset(3)..MultiBufferOffset(3 + initial_edit + 4)],
"Client should get its first semantic tokens when opening an editor"
);
});
let after_client_edit = edits_made.fetch_add(1, atomic::Ordering::Release) + 1;
editor_b.update_in(cx_b, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges([MultiBufferOffset(13)..MultiBufferOffset(13)].clone())
});
editor.handle_input(":", window, cx);
});
cx_b.focus(&editor_b);
executor.advance_clock(Duration::from_secs(1));
executor.run_until_parked();
editor_a.update(cx_a, |editor, cx| {
let ranges = extract_semantic_token_ranges(editor, cx);
assert_eq!(
ranges,
vec![MultiBufferOffset(3)..MultiBufferOffset(3 + after_client_edit + 4)],
);
});
editor_b.update(cx_b, |editor, cx| {
let ranges = extract_semantic_token_ranges(editor, cx);
assert_eq!(
ranges,
vec![MultiBufferOffset(3)..MultiBufferOffset(3 + after_client_edit + 4)],
);
});
let after_host_edit = edits_made.fetch_add(1, atomic::Ordering::Release) + 1;
editor_a.update_in(cx_a, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges([MultiBufferOffset(14)..MultiBufferOffset(14)])
});
editor.handle_input("a change", window, cx);
});
cx_a.focus(&editor_a);
executor.advance_clock(Duration::from_secs(1));
executor.run_until_parked();
editor_a.update(cx_a, |editor, cx| {
let ranges = extract_semantic_token_ranges(editor, cx);
assert_eq!(
ranges,
vec![MultiBufferOffset(3)..MultiBufferOffset(3 + after_host_edit + 4)],
);
});
editor_b.update(cx_b, |editor, cx| {
let ranges = extract_semantic_token_ranges(editor, cx);
assert_eq!(
ranges,
vec![MultiBufferOffset(3)..MultiBufferOffset(3 + after_host_edit + 4)],
);
});
}
#[gpui::test(iterations = 10)]
async fn test_semantic_token_refresh_is_forwarded(
cx_a: &mut TestAppContext,
cx_b: &mut TestAppContext,
) {
let mut server = TestServer::start(cx_a.executor()).await;
let executor = cx_a.executor();
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
let active_call_a = cx_a.read(ActiveCall::global);
let active_call_b = cx_b.read(ActiveCall::global);
cx_a.update(editor::init);
cx_b.update(editor::init);
cx_a.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings.project.all_languages.defaults.semantic_tokens = Some(SemanticTokens::Off);
});
});
});
cx_b.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings.project.all_languages.defaults.semantic_tokens =
Some(SemanticTokens::Full);
});
});
});
let capabilities = lsp::ServerCapabilities {
semantic_tokens_provider: Some(
lsp::SemanticTokensServerCapabilities::SemanticTokensOptions(
lsp::SemanticTokensOptions {
legend: lsp::SemanticTokensLegend {
token_types: vec!["function".into()],
token_modifiers: vec![],
},
full: Some(lsp::SemanticTokensFullOptions::Delta { delta: None }),
..Default::default()
},
),
),
..lsp::ServerCapabilities::default()
};
client_a.language_registry().add(rust_lang());
let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
"Rust",
FakeLspAdapter {
capabilities: capabilities.clone(),
..FakeLspAdapter::default()
},
);
client_b.language_registry().add(rust_lang());
client_b.language_registry().register_fake_lsp_adapter(
"Rust",
FakeLspAdapter {
capabilities,
..FakeLspAdapter::default()
},
);
client_a
.fs()
.insert_tree(
path!("/a"),
json!({
"main.rs": "fn main() { a }",
"other.rs": "// Test file",
}),
)
.await;
let (project_a, worktree_id) = client_a.build_local_project(path!("/a"), cx_a).await;
active_call_a
.update(cx_a, |call, cx| call.set_location(Some(&project_a), cx))
.await
.unwrap();
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
let project_b = client_b.join_remote_project(project_id, cx_b).await;
active_call_b
.update(cx_b, |call, cx| call.set_location(Some(&project_b), cx))
.await
.unwrap();
let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a);
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
let editor_a = workspace_a
.update_in(cx_a, |workspace, window, cx| {
workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
let editor_b = workspace_b
.update_in(cx_b, |workspace, window, cx| {
workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
let other_tokens = Arc::new(AtomicBool::new(false));
let fake_language_server = fake_language_servers.next().await.unwrap();
let closure_other_tokens = Arc::clone(&other_tokens);
fake_language_server
.set_request_handler::<lsp::request::SemanticTokensFullRequest, _, _>(move |params, _| {
let task_other_tokens = Arc::clone(&closure_other_tokens);
async move {
assert_eq!(
params.text_document.uri,
lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(),
);
let other_tokens = task_other_tokens.load(atomic::Ordering::Acquire);
let (delta_start, length) = if other_tokens { (0, 2) } else { (3, 4) };
Ok(Some(lsp::SemanticTokensResult::Tokens(
lsp::SemanticTokens {
data: vec![
0, // delta_line
delta_start,
length,
0, // token_type
0, // token_modifiers_bitset
],
result_id: None,
},
)))
}
})
.next()
.await
.unwrap();
executor.run_until_parked();
editor_a.update(cx_a, |editor, cx| {
assert!(
extract_semantic_token_ranges(editor, cx).is_empty(),
"Host should get no semantic tokens due to them turned off"
);
});
executor.run_until_parked();
editor_b.update(cx_b, |editor, cx| {
assert_eq!(
vec![MultiBufferOffset(3)..MultiBufferOffset(7)],
extract_semantic_token_ranges(editor, cx),
"Client should get its first semantic tokens when opening an editor"
);
});
other_tokens.fetch_or(true, atomic::Ordering::Release);
fake_language_server
.request::<lsp::request::SemanticTokensRefresh>((), DEFAULT_LSP_REQUEST_TIMEOUT)
.await
.into_response()
.expect("semantic tokens refresh request failed");
// wait out the debounce timeout
executor.advance_clock(LSP_REQUEST_DEBOUNCE_TIMEOUT);
executor.run_until_parked();
editor_a.update(cx_a, |editor, cx| {
assert!(
extract_semantic_token_ranges(editor, cx).is_empty(),
"Host should get no semantic tokens due to them turned off, even after the /refresh"
);
});
executor.run_until_parked();
editor_b.update(cx_b, |editor, cx| {
assert_eq!(
vec![MultiBufferOffset(0)..MultiBufferOffset(2)],
extract_semantic_token_ranges(editor, cx),
"Guest should get a /refresh LSP request propagated by host despite host tokens are off"
);
});
}
#[gpui::test]
async fn test_document_folding_ranges(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
let mut server = TestServer::start(cx_a.executor()).await;
let executor = cx_a.executor();
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
let active_call_a = cx_a.read(ActiveCall::global);
let active_call_b = cx_b.read(ActiveCall::global);
cx_a.update(editor::init);
cx_b.update(editor::init);
let capabilities = lsp::ServerCapabilities {
folding_range_provider: Some(lsp::FoldingRangeProviderCapability::Simple(true)),
..lsp::ServerCapabilities::default()
};
client_a.language_registry().add(rust_lang());
let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
"Rust",
FakeLspAdapter {
capabilities: capabilities.clone(),
..FakeLspAdapter::default()
},
);
client_b.language_registry().add(rust_lang());
client_b.language_registry().register_fake_lsp_adapter(
"Rust",
FakeLspAdapter {
capabilities,
..FakeLspAdapter::default()
},
);
client_a
.fs()
.insert_tree(
path!("/a"),
json!({
"main.rs": "fn main() {\n if true {\n println!(\"hello\");\n }\n}\n",
}),
)
.await;
let (project_a, worktree_id) = client_a.build_local_project(path!("/a"), cx_a).await;
active_call_a
.update(cx_a, |call, cx| call.set_location(Some(&project_a), cx))
.await
.unwrap();
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
let project_b = client_b.join_remote_project(project_id, cx_b).await;
active_call_b
.update(cx_b, |call, cx| call.set_location(Some(&project_b), cx))
.await
.unwrap();
let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a);
let _buffer_a = project_a
.update(cx_a, |project, cx| {
project.open_local_buffer(path!("/a/main.rs"), cx)
})
.await
.unwrap();
let editor_a = workspace_a
.update_in(cx_a, |workspace, window, cx| {
workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
let fake_language_server = fake_language_servers.next().await.unwrap();
let folding_request_count = Arc::new(AtomicUsize::new(0));
let closure_count = Arc::clone(&folding_request_count);
let mut folding_request_handle = fake_language_server
.set_request_handler::<lsp::request::FoldingRangeRequest, _, _>(move |_, _| {
let count = Arc::clone(&closure_count);
async move {
count.fetch_add(1, atomic::Ordering::Release);
Ok(Some(vec![lsp::FoldingRange {
start_line: 0,
start_character: Some(10),
end_line: 4,
end_character: Some(1),
kind: None,
collapsed_text: None,
}]))
}
});
executor.run_until_parked();
assert_eq!(
0,
folding_request_count.load(atomic::Ordering::Acquire),
"LSP folding ranges are off by default, no request should have been made"
);
editor_a.update(cx_a, |editor, cx| {
assert!(
!editor.document_folding_ranges_enabled(cx),
"Host should not have LSP folding ranges enabled"
);
});
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
let editor_b = workspace_b
.update_in(cx_b, |workspace, window, cx| {
workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
executor.run_until_parked();
editor_b.update(cx_b, |editor, cx| {
assert!(
!editor.document_folding_ranges_enabled(cx),
"Client should not have LSP folding ranges enabled by default"
);
});
cx_b.update(|_, cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings
.project
.all_languages
.defaults
.document_folding_ranges = Some(DocumentFoldingRanges::On);
});
});
});
executor.advance_clock(LSP_REQUEST_DEBOUNCE_TIMEOUT);
folding_request_handle.next().await.unwrap();
executor.run_until_parked();
assert!(
folding_request_count.load(atomic::Ordering::Acquire) > 0,
"After the client enables LSP folding ranges, a request should be made"
);
editor_b.update(cx_b, |editor, cx| {
assert!(
editor.document_folding_ranges_enabled(cx),
"Client should have LSP folding ranges enabled after toggling the setting on"
);
});
editor_a.update(cx_a, |editor, cx| {
assert!(
!editor.document_folding_ranges_enabled(cx),
"Host should remain unaffected by the client's setting change"
);
});
editor_b.update_in(cx_b, |editor, window, cx| {
let snapshot = editor.display_snapshot(cx);
assert!(
!snapshot.is_line_folded(MultiBufferRow(0)),
"Line 0 should not be folded before fold_at"
);
editor.fold_at(MultiBufferRow(0), window, cx);
});
executor.run_until_parked();
editor_b.update(cx_b, |editor, cx| {
let snapshot = editor.display_snapshot(cx);
assert!(
snapshot.is_line_folded(MultiBufferRow(0)),
"Line 0 should be folded after fold_at using LSP folding range"
);
});
}
#[gpui::test]
async fn test_remote_project_worktree_trust(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
let has_restricted_worktrees = |project: &gpui::Entity<project::Project>,
cx: &mut VisualTestContext| {
cx.update(|_, cx| {
let worktree_store = project.read(cx).worktree_store();
TrustedWorktrees::try_get_global(cx)
.unwrap()
.read(cx)
.has_restricted_worktrees(&worktree_store, cx)
})
};
cx_a.update(|cx| {
project::trusted_worktrees::init(HashMap::default(), cx);
});
cx_b.update(|cx| {
project::trusted_worktrees::init(HashMap::default(), cx);
});
let mut server = TestServer::start(cx_a.executor()).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
client_a
.fs()
.insert_tree(
path!("/a"),
json!({
"file.txt": "contents",
}),
)
.await;
let (project_a, worktree_id) = client_a
.build_local_project_with_trust(path!("/a"), cx_a)
.await;
let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a);
let active_call_a = cx_a.read(ActiveCall::global);
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
let project_b = client_b.join_remote_project(project_id, cx_b).await;
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
let _editor_a = workspace_a
.update_in(cx_a, |workspace, window, cx| {
workspace.open_path(
(worktree_id, rel_path("src/main.rs")),
None,
true,
window,
cx,
)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
let _editor_b = workspace_b
.update_in(cx_b, |workspace, window, cx| {
workspace.open_path(
(worktree_id, rel_path("src/main.rs")),
None,
true,
window,
cx,
)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
cx_a.run_until_parked();
cx_b.run_until_parked();
assert!(
has_restricted_worktrees(&project_a, cx_a),
"local client should have restricted worktrees after opening it"
);
assert!(
!has_restricted_worktrees(&project_b, cx_b),
"remote client joined a project should have no restricted worktrees"
);
cx_a.update(|_, cx| {
if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
trusted_worktrees.update(cx, |trusted_worktrees, cx| {
trusted_worktrees.trust(
&project_a.read(cx).worktree_store(),
HashSet::from_iter([PathTrust::Worktree(worktree_id)]),
cx,
);
});
}
});
assert!(
!has_restricted_worktrees(&project_a, cx_a),
"local client should have no worktrees after trusting those"
);
assert!(
!has_restricted_worktrees(&project_b, cx_b),
"remote client should still be trusted"
);
}
#[gpui::test]
async fn test_document_symbols(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
let mut server = TestServer::start(cx_a.executor()).await;
let executor = cx_a.executor();
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
.await;
let active_call_a = cx_a.read(ActiveCall::global);
let active_call_b = cx_b.read(ActiveCall::global);
cx_a.update(editor::init);
cx_b.update(editor::init);
let capabilities = lsp::ServerCapabilities {
document_symbol_provider: Some(lsp::OneOf::Left(true)),
..lsp::ServerCapabilities::default()
};
client_a.language_registry().add(rust_lang());
#[allow(deprecated)]
let mut fake_language_servers = client_a.language_registry().register_fake_lsp(
"Rust",
FakeLspAdapter {
capabilities: capabilities.clone(),
initializer: Some(Box::new(|fake_language_server| {
#[allow(deprecated)]
fake_language_server
.set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
move |_, _| async move {
Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
lsp::DocumentSymbol {
name: "Foo".to_string(),
detail: None,
kind: lsp::SymbolKind::STRUCT,
tags: None,
deprecated: None,
range: lsp::Range::new(
lsp::Position::new(0, 0),
lsp::Position::new(2, 1),
),
selection_range: lsp::Range::new(
lsp::Position::new(0, 7),
lsp::Position::new(0, 10),
),
children: Some(vec![lsp::DocumentSymbol {
name: "bar".to_string(),
detail: None,
kind: lsp::SymbolKind::FIELD,
tags: None,
deprecated: None,
range: lsp::Range::new(
lsp::Position::new(1, 4),
lsp::Position::new(1, 13),
),
selection_range: lsp::Range::new(
lsp::Position::new(1, 4),
lsp::Position::new(1, 7),
),
children: None,
}]),
},
])))
},
);
})),
..FakeLspAdapter::default()
},
);
client_b.language_registry().add(rust_lang());
client_b.language_registry().register_fake_lsp_adapter(
"Rust",
FakeLspAdapter {
capabilities,
..FakeLspAdapter::default()
},
);
client_a
.fs()
.insert_tree(
path!("/a"),
json!({
"main.rs": "struct Foo {\n bar: u32,\n}\n",
}),
)
.await;
let (project_a, worktree_id) = client_a.build_local_project(path!("/a"), cx_a).await;
active_call_a
.update(cx_a, |call, cx| call.set_location(Some(&project_a), cx))
.await
.unwrap();
let project_id = active_call_a
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
.await
.unwrap();
let project_b = client_b.join_remote_project(project_id, cx_b).await;
active_call_b
.update(cx_b, |call, cx| call.set_location(Some(&project_b), cx))
.await
.unwrap();
let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a);
let editor_a = workspace_a
.update_in(cx_a, |workspace, window, cx| {
workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
let _fake_language_server = fake_language_servers.next().await.unwrap();
executor.run_until_parked();
cx_a.update(|_, cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings.project.all_languages.defaults.document_symbols =
Some(DocumentSymbols::On);
});
});
});
executor.advance_clock(LSP_REQUEST_DEBOUNCE_TIMEOUT + Duration::from_millis(100));
executor.run_until_parked();
editor_a.update(cx_a, |editor, cx| {
let (breadcrumbs, _) = editor
.breadcrumbs(cx)
.expect("Host should have breadcrumbs");
let texts: Vec<_> = breadcrumbs.iter().map(|b| b.text.as_str()).collect();
assert_eq!(
texts,
vec!["main.rs", "struct Foo"],
"Host should see file path and LSP symbol 'Foo' in breadcrumbs"
);
});
cx_b.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
settings.project.all_languages.defaults.document_symbols =
Some(DocumentSymbols::On);
});
});
});
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
let editor_b = workspace_b
.update_in(cx_b, |workspace, window, cx| {
workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx)
})
.await
.unwrap()
.downcast::<Editor>()
.unwrap();
executor.advance_clock(LSP_REQUEST_DEBOUNCE_TIMEOUT + Duration::from_millis(100));
executor.run_until_parked();
editor_b.update(cx_b, |editor, cx| {
assert_eq!(
editor
.breadcrumbs(cx)
.expect("Client B should have breadcrumbs")
.0
.iter()
.map(|b| b.text.as_str())
.collect::<Vec<_>>(),
vec!["main.rs", "struct Foo"],
"Client B should see file path and LSP symbol 'Foo' via remote project"
);
});
}
fn blame_entry(sha: &str, range: Range<u32>) -> git::blame::BlameEntry {
git::blame::BlameEntry {
sha: sha.parse().unwrap(),
range,
original_line_number: 0,
author: None,
author_mail: None,
author_time: None,
author_tz: None,
committer_name: None,
committer_email: None,
committer_time: None,
committer_tz: None,
summary: None,
previous: None,
filename: String::new(),
}
}