
As an attempt to do things better when showing diff hunks, store diff base as Rope, not String, to have cheaper clones when the diff base text is reused, e.g. creating another buffer with the diff base text for hunk diff expanding. Release Notes: - N/A
2438 lines
84 KiB
Rust
2438 lines
84 KiB
Rust
use crate::{
|
|
rpc::RECONNECT_TIMEOUT,
|
|
tests::{rust_lang, TestServer},
|
|
};
|
|
use call::ActiveCall;
|
|
use collections::HashMap;
|
|
use editor::{
|
|
actions::{
|
|
ConfirmCodeAction, ConfirmCompletion, ConfirmRename, ContextMenuFirst, Redo, Rename,
|
|
RevertSelectedHunks, ToggleCodeActions, Undo,
|
|
},
|
|
test::{
|
|
editor_hunks,
|
|
editor_test_context::{AssertionContextManager, EditorTestContext},
|
|
expanded_hunks, expanded_hunks_background_highlights,
|
|
},
|
|
Editor,
|
|
};
|
|
use futures::StreamExt;
|
|
use git::diff::DiffHunkStatus;
|
|
use gpui::{BorrowAppContext, TestAppContext, VisualContext, VisualTestContext};
|
|
use indoc::indoc;
|
|
use language::{
|
|
language_settings::{AllLanguageSettings, InlayHintSettings},
|
|
FakeLspAdapter,
|
|
};
|
|
use project::{
|
|
project_settings::{InlineBlameSettings, ProjectSettings},
|
|
SERVER_PROGRESS_DEBOUNCE_TIMEOUT,
|
|
};
|
|
use rpc::RECEIVE_TIMEOUT;
|
|
use serde_json::json;
|
|
use settings::SettingsStore;
|
|
use std::{
|
|
ops::Range,
|
|
path::Path,
|
|
sync::{
|
|
atomic::{self, AtomicBool, AtomicUsize},
|
|
Arc,
|
|
},
|
|
};
|
|
use text::Point;
|
|
use workspace::{Workspace, WorkspaceId};
|
|
|
|
#[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);
|
|
|
|
client_a
|
|
.fs()
|
|
.insert_tree(
|
|
"/a",
|
|
serde_json::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("/a", cx_a).await;
|
|
|
|
let worktree_a = project_a.read_with(cx_a, |project, _| project.worktrees().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.build_dev_server_project(project_id, cx_b).await;
|
|
cx_a.background_executor.run_until_parked();
|
|
|
|
assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared()));
|
|
|
|
let workspace_b = cx_b.add_window(|cx| {
|
|
Workspace::new(
|
|
WorkspaceId::default(),
|
|
project_b.clone(),
|
|
client_b.app_state.clone(),
|
|
cx,
|
|
)
|
|
});
|
|
let cx_b = &mut VisualTestContext::from_window(*workspace_b, cx_b);
|
|
let workspace_b_view = workspace_b.root_view(cx_b).unwrap();
|
|
|
|
let editor_b = workspace_b
|
|
.update(cx_b, |workspace, cx| {
|
|
workspace.open_path((worktree_id, "b.txt"), None, true, cx)
|
|
})
|
|
.unwrap()
|
|
.await
|
|
.unwrap()
|
|
.downcast::<Editor>()
|
|
.unwrap();
|
|
|
|
//TODO: focus
|
|
assert!(cx_b.update_view(&editor_b, |editor, cx| editor.is_focused(cx)));
|
|
editor_b.update(cx_b, |editor, cx| editor.insert("X", cx));
|
|
|
|
cx_b.update(|cx| {
|
|
assert!(workspace_b_view.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, _| project.is_read_only());
|
|
|
|
assert!(worktree_a.read_with(cx_a, |tree, _| !tree.as_local().unwrap().is_shared()));
|
|
|
|
// Ensure client B's edited state is reset and that the whole window is blurred.
|
|
|
|
workspace_b
|
|
.update(cx_b, |workspace, cx| {
|
|
assert_eq!(cx.focused(), None);
|
|
assert!(!workspace.is_edited())
|
|
})
|
|
.unwrap();
|
|
|
|
// Ensure client B is not prompted to save edits when closing window after disconnecting.
|
|
let can_close = workspace_b
|
|
.update(cx_b, |workspace, cx| workspace.prepare_to_close(true, cx))
|
|
.unwrap()
|
|
.await
|
|
.unwrap();
|
|
assert!(can_close);
|
|
|
|
// Allow client A to reconnect to the server.
|
|
server.allow_connections();
|
|
cx_a.background_executor.advance_clock(RECEIVE_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("/dir", json!({ "a.txt": "Some text\n" }))
|
|
.await;
|
|
let (project_a, worktree_id) = client_a.build_local_project("/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.build_dev_server_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, "a.txt"), cx))
|
|
.await
|
|
.unwrap();
|
|
let cx_a = cx_a.add_empty_window();
|
|
let editor_a = cx_a.new_view(|cx| Editor::for_buffer(buffer_a, Some(project_a), cx));
|
|
|
|
let mut editor_cx_a = EditorTestContext {
|
|
cx: cx_a.clone(),
|
|
window: cx_a.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, "a.txt"), cx))
|
|
.await
|
|
.unwrap();
|
|
let editor_b = cx_b.new_view(|cx| Editor::for_buffer(buffer_b, Some(project_b), cx));
|
|
|
|
let mut editor_cx_b = EditorTestContext {
|
|
cx: cx_b.clone(),
|
|
window: cx_b.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, cx| editor.newline_above(&editor::actions::NewlineAbove, 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, cx| editor.newline_below(&editor::actions::NewlineBelow, 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(iterations = 10)]
|
|
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);
|
|
|
|
client_a.language_registry().add(rust_lang());
|
|
let mut fake_language_servers = client_a.language_registry().register_fake_lsp_adapter(
|
|
"Rust",
|
|
FakeLspAdapter {
|
|
capabilities: lsp::ServerCapabilities {
|
|
completion_provider: Some(lsp::CompletionOptions {
|
|
trigger_characters: Some(vec![".".to_string()]),
|
|
resolve_provider: Some(true),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
},
|
|
);
|
|
|
|
client_a
|
|
.fs()
|
|
.insert_tree(
|
|
"/a",
|
|
json!({
|
|
"main.rs": "fn main() { a }",
|
|
"other.rs": "",
|
|
}),
|
|
)
|
|
.await;
|
|
let (project_a, worktree_id) = client_a.build_local_project("/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.build_dev_server_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, "main.rs"), cx))
|
|
.await
|
|
.unwrap();
|
|
let cx_b = cx_b.add_empty_window();
|
|
let editor_b =
|
|
cx_b.new_view(|cx| Editor::for_buffer(buffer_b.clone(), Some(project_b.clone()), cx));
|
|
|
|
let fake_language_server = fake_language_servers.next().await.unwrap();
|
|
cx_a.background_executor.run_until_parked();
|
|
|
|
buffer_b.read_with(cx_b, |buffer, _| {
|
|
assert!(!buffer.completion_triggers().is_empty())
|
|
});
|
|
|
|
// Type a completion trigger character as the guest.
|
|
editor_b.update(cx_b, |editor, cx| {
|
|
editor.change_selections(None, cx, |s| s.select_ranges([13..13]));
|
|
editor.handle_input(".", cx);
|
|
});
|
|
cx_b.focus_view(&editor_b);
|
|
|
|
// Receive a completion request as the host's language server.
|
|
// Return some completions from the host's language server.
|
|
cx_a.executor().start_waiting();
|
|
fake_language_server
|
|
.handle_request::<lsp::request::Completion, _, _>(|params, _| async move {
|
|
assert_eq!(
|
|
params.text_document_position.text_document.uri,
|
|
lsp::Url::from_file_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()
|
|
},
|
|
])))
|
|
})
|
|
.next()
|
|
.await
|
|
.unwrap();
|
|
cx_a.executor().finish_waiting();
|
|
|
|
// Open the buffer on the host.
|
|
let buffer_a = project_a
|
|
.update(cx_a, |p, cx| p.open_buffer((worktree_id, "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(cx_b, |editor, cx| {
|
|
assert!(editor.context_menu_visible());
|
|
editor.confirm_completion(&ConfirmCompletion { item_ix: Some(0) }, 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.handle_request::<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();
|
|
|
|
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(cx_b, |editor, cx| {
|
|
editor.change_selections(None, cx, |s| s.select_ranges([46..46]));
|
|
editor.handle_input("; a", cx);
|
|
editor.handle_input(".", 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
|
|
.handle_request::<lsp::request::Completion, _, _>(|params, _| async move {
|
|
assert_eq!(
|
|
params.text_document_position.text_document.uri,
|
|
lsp::Url::from_file_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 placehodlers
|
|
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()
|
|
},
|
|
])))
|
|
});
|
|
|
|
// The completion now gets a new `text_edit.new_text` when resolving the completion item
|
|
let mut resolve_completion_response = fake_language_server
|
|
.handle_request::<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();
|
|
|
|
editor_b.update(cx_b, |editor, cx| {
|
|
assert!(editor.context_menu_visible());
|
|
editor.context_menu_first(&ContextMenuFirst {}, cx);
|
|
});
|
|
|
|
resolve_completion_response.next().await.unwrap();
|
|
cx_b.executor().run_until_parked();
|
|
|
|
// When accepting the completion, the snippet is insert.
|
|
editor_b.update(cx_b, |editor, cx| {
|
|
assert!(editor.context_menu_visible());
|
|
editor.confirm_completion(&ConfirmCompletion { item_ix: Some(0) }, cx);
|
|
assert_eq!(
|
|
editor.text(cx),
|
|
"use d::SomeTrait;\nfn main() { a.first_method(); a.third_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);
|
|
|
|
// Set up a fake language server.
|
|
client_a.language_registry().add(rust_lang());
|
|
let mut fake_language_servers = client_a
|
|
.language_registry()
|
|
.register_fake_lsp_adapter("Rust", FakeLspAdapter::default());
|
|
|
|
client_a
|
|
.fs()
|
|
.insert_tree(
|
|
"/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("/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.build_dev_server_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(cx_b, |workspace, cx| {
|
|
workspace.open_path((worktree_id, "main.rs"), None, true, cx)
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.downcast::<Editor>()
|
|
.unwrap();
|
|
|
|
let mut fake_language_server = fake_language_servers.next().await.unwrap();
|
|
let mut requests = fake_language_server
|
|
.handle_request::<lsp::request::CodeActionRequest, _, _>(|params, _| async move {
|
|
assert_eq!(
|
|
params.text_document.uri,
|
|
lsp::Url::from_file_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(cx_b, |editor, cx| {
|
|
editor.change_selections(None, cx, |s| {
|
|
s.select_ranges([Point::new(1, 31)..Point::new(1, 31)])
|
|
});
|
|
});
|
|
cx_b.focus_view(&editor_b);
|
|
|
|
let mut requests = fake_language_server
|
|
.handle_request::<lsp::request::CodeActionRequest, _, _>(|params, _| async move {
|
|
assert_eq!(
|
|
params.text_document.uri,
|
|
lsp::Url::from_file_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::Url::from_file_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::Url::from_file_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(cx_b, |editor, cx| {
|
|
editor.toggle_code_actions(
|
|
&ToggleCodeActions {
|
|
deployed_from_indicator: false,
|
|
},
|
|
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(cx_b, |editor, cx| {
|
|
Editor::confirm_code_action(editor, &ConfirmCodeAction { item_ix: Some(0) }, cx)
|
|
})
|
|
.unwrap();
|
|
fake_language_server.handle_request::<lsp::request::CodeActionResolveRequest, _, _>(
|
|
|_, _| async move {
|
|
Ok(lsp::CodeAction {
|
|
title: "Inline into all callers".to_string(),
|
|
edit: Some(lsp::WorkspaceEdit {
|
|
changes: Some(
|
|
[
|
|
(
|
|
lsp::Url::from_file_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::Url::from_file_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(cx_b, |editor, cx| {
|
|
assert_eq!(editor.text(cx), "mod other;\nfn main() { let foo = 4; }\n");
|
|
editor.undo(&Undo, cx);
|
|
assert_eq!(
|
|
editor.text(cx),
|
|
"mod other;\nfn main() { let foo = other::foo(); }\npub fn foo() -> usize { 4 }"
|
|
);
|
|
editor.redo(&Redo, 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);
|
|
|
|
// Set up a fake language server.
|
|
client_a.language_registry().add(rust_lang());
|
|
let mut fake_language_servers = client_a.language_registry().register_fake_lsp_adapter(
|
|
"Rust",
|
|
FakeLspAdapter {
|
|
capabilities: lsp::ServerCapabilities {
|
|
rename_provider: Some(lsp::OneOf::Right(lsp::RenameOptions {
|
|
prepare_provider: Some(true),
|
|
work_done_progress_options: Default::default(),
|
|
})),
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
},
|
|
);
|
|
|
|
client_a
|
|
.fs()
|
|
.insert_tree(
|
|
"/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("/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.build_dev_server_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(cx_b, |workspace, cx| {
|
|
workspace.open_path((worktree_id, "one.rs"), None, true, cx)
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.downcast::<Editor>()
|
|
.unwrap();
|
|
let fake_language_server = fake_language_servers.next().await.unwrap();
|
|
|
|
// Move cursor to a location that can be renamed.
|
|
let prepare_rename = editor_b.update(cx_b, |editor, cx| {
|
|
editor.change_selections(None, cx, |s| s.select_ranges([7..7]));
|
|
editor.rename(&Rename, cx).unwrap()
|
|
});
|
|
|
|
fake_language_server
|
|
.handle_request::<lsp::request::PrepareRenameRequest, _, _>(|params, _| async move {
|
|
assert_eq!(params.text_document.uri.as_str(), "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),
|
|
6..9
|
|
);
|
|
rename.editor.update(cx, |rename_editor, cx| {
|
|
let rename_selection = rename_editor.selections.newest::<usize>(cx);
|
|
assert_eq!(
|
|
rename_selection.range(),
|
|
0..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([(0..3, "THREE")], None, cx);
|
|
});
|
|
});
|
|
});
|
|
|
|
// Cancel the rename, and repeat the same, but use selections instead of cursor movement
|
|
editor_b.update(cx_b, |editor, cx| {
|
|
editor.cancel(&editor::actions::Cancel, cx);
|
|
});
|
|
let prepare_rename = editor_b.update(cx_b, |editor, cx| {
|
|
editor.change_selections(None, cx, |s| s.select_ranges([7..8]));
|
|
editor.rename(&Rename, cx).unwrap()
|
|
});
|
|
|
|
fake_language_server
|
|
.handle_request::<lsp::request::PrepareRenameRequest, _, _>(|params, _| async move {
|
|
assert_eq!(params.text_document.uri.as_str(), "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, 6..9);
|
|
rename.editor.update(cx, |rename_editor, cx| {
|
|
let rename_selection = rename_editor.selections.newest::<usize>(cx);
|
|
assert_eq!(
|
|
rename_selection.range(),
|
|
1..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([(0..lsp_rename_end - lsp_rename_start, "THREE")], None, cx);
|
|
});
|
|
});
|
|
});
|
|
|
|
let confirm_rename = editor_b.update(cx_b, |editor, cx| {
|
|
Editor::confirm_rename(editor, &ConfirmRename, cx).unwrap()
|
|
});
|
|
fake_language_server
|
|
.handle_request::<lsp::request::Rename, _, _>(|params, _| async move {
|
|
assert_eq!(
|
|
params.text_document_position.text_document.uri.as_str(),
|
|
"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::Url::from_file_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::Url::from_file_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(cx_b, |editor, cx| {
|
|
assert_eq!(
|
|
editor.text(cx),
|
|
"const THREE: usize = 1;\nconst TWO: usize = one::THREE + one::THREE;"
|
|
);
|
|
editor.undo(&Undo, cx);
|
|
assert_eq!(
|
|
editor.text(cx),
|
|
"const ONE: usize = 1;\nconst TWO: usize = one::ONE + one::ONE;"
|
|
);
|
|
editor.redo(&Redo, 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(cx_b, |editor, cx| {
|
|
editor.undo(&Undo, cx);
|
|
assert_eq!(editor.text(cx), "const ONE: usize = 1;");
|
|
editor.undo(&Undo, cx);
|
|
assert_eq!(editor.text(cx), "const ONE: usize = 1;");
|
|
editor.redo(&Redo, cx);
|
|
assert_eq!(editor.text(cx), "const THREE: usize = 1;");
|
|
})
|
|
}
|
|
|
|
#[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_adapter(
|
|
"Rust",
|
|
FakeLspAdapter {
|
|
name: "the-language-server",
|
|
..Default::default()
|
|
},
|
|
);
|
|
|
|
client_a
|
|
.fs()
|
|
.insert_tree(
|
|
"/dir",
|
|
json!({
|
|
"main.rs": "const ONE: usize = 1;",
|
|
}),
|
|
)
|
|
.await;
|
|
let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await;
|
|
|
|
let _buffer_a = project_a
|
|
.update(cx_a, |p, cx| p.open_buffer((worktree_id, "main.rs"), cx))
|
|
.await
|
|
.unwrap();
|
|
|
|
let fake_language_server = fake_language_servers.next().await.unwrap();
|
|
fake_language_server.start_progress("the-token").await;
|
|
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.advance_clock(SERVER_PROGRESS_DEBOUNCE_TIMEOUT);
|
|
executor.run_until_parked();
|
|
|
|
project_a.read_with(cx_a, |project, _| {
|
|
let status = project.language_server_statuses().next().unwrap();
|
|
assert_eq!(status.name, "the-language-server");
|
|
assert_eq!(status.pending_work.len(), 1);
|
|
assert_eq!(
|
|
status.pending_work["the-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.build_dev_server_project(project_id, cx_b).await;
|
|
|
|
project_b.read_with(cx_b, |project, _| {
|
|
let status = project.language_server_statuses().next().unwrap();
|
|
assert_eq!(status.name, "the-language-server");
|
|
});
|
|
|
|
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.advance_clock(SERVER_PROGRESS_DEBOUNCE_TIMEOUT);
|
|
executor.run_until_parked();
|
|
|
|
project_a.read_with(cx_a, |project, _| {
|
|
let status = project.language_server_statuses().next().unwrap();
|
|
assert_eq!(status.name, "the-language-server");
|
|
assert_eq!(status.pending_work.len(), 1);
|
|
assert_eq!(
|
|
status.pending_work["the-token"].message.as_ref().unwrap(),
|
|
"the-message-2"
|
|
);
|
|
});
|
|
|
|
project_b.read_with(cx_b, |project, _| {
|
|
let status = project.language_server_statuses().next().unwrap();
|
|
assert_eq!(status.name, "the-language-server");
|
|
assert_eq!(status.pending_work.len(), 1);
|
|
assert_eq!(
|
|
status.pending_work["the-token"].message.as_ref().unwrap(),
|
|
"the-message-2"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[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(
|
|
"/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("/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.github_login, "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
|
|
.build_dev_server_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().next().unwrap().read(cx);
|
|
assert_eq!(
|
|
worktree.paths().map(AsRef::as_ref).collect::<Vec<_>>(),
|
|
[
|
|
Path::new(".gitignore"),
|
|
Path::new("a.txt"),
|
|
Path::new("b.txt"),
|
|
Path::new("ignored-dir"),
|
|
]
|
|
);
|
|
});
|
|
|
|
project_b
|
|
.update(cx_b, |project, cx| {
|
|
let worktree = project.worktrees().next().unwrap();
|
|
let entry = worktree.read(cx).entry_for_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().next().unwrap().read(cx);
|
|
assert_eq!(
|
|
worktree.paths().map(AsRef::as_ref).collect::<Vec<_>>(),
|
|
[
|
|
Path::new(".gitignore"),
|
|
Path::new("a.txt"),
|
|
Path::new("b.txt"),
|
|
Path::new("ignored-dir"),
|
|
Path::new("ignored-dir/c.txt"),
|
|
Path::new("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, "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, "b.txt"), cx))
|
|
});
|
|
let buffer_a = project_a
|
|
.update(cx_a, |p, cx| p.open_buffer((worktree_id, "b.txt"), cx))
|
|
.await
|
|
.unwrap();
|
|
|
|
let editor_b = cx_b.new_view(|cx| Editor::for_buffer(buffer_b, None, cx));
|
|
|
|
// Client A sees client B's selection
|
|
executor.run_until_parked();
|
|
|
|
buffer_a.read_with(cx_a, |buffer, _| {
|
|
buffer
|
|
.snapshot()
|
|
.remote_selections_in_range(text::Anchor::MIN..text::Anchor::MAX)
|
|
.count()
|
|
== 1
|
|
});
|
|
|
|
// Edit the buffer as client B and see that edit as client A.
|
|
editor_b.update(cx_b, |editor, cx| editor.handle_input("ok, ", 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.github_login, "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
|
|
.build_dev_server_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()
|
|
.remote_selections_in_range(text::Anchor::MIN..text::Anchor::MAX)
|
|
.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_adapter(
|
|
"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(
|
|
"/a",
|
|
json!({
|
|
"main.rs": "fn main() { a }",
|
|
"other.rs": "// Test file",
|
|
}),
|
|
)
|
|
.await;
|
|
let (project_a, worktree_id) = client_a.build_local_project("/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.build_dev_server_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, "main.rs"), cx))
|
|
.await
|
|
.unwrap();
|
|
let cx_a = cx_a.add_empty_window();
|
|
let editor_a = cx_a.new_view(|cx| Editor::for_buffer(buffer_a, Some(project_a.clone()), 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.handle_request::<lsp::request::OnTypeFormatting, _, _>(
|
|
|params, _| async move {
|
|
assert_eq!(
|
|
params.text_document_position.text_document.uri,
|
|
lsp::Url::from_file_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, "main.rs"), cx))
|
|
.await
|
|
.unwrap();
|
|
|
|
// Type a on type formatting trigger character as the guest.
|
|
cx_a.focus_view(&editor_a);
|
|
editor_a.update(cx_a, |editor, cx| {
|
|
editor.change_selections(None, cx, |s| s.select_ranges([13..13]));
|
|
editor.handle_input(">", 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(cx_a, |editor, cx| {
|
|
assert_eq!(editor.text(cx), "fn main() { a>~< }");
|
|
editor.undo(&Undo, 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(cx_a, |editor, cx| {
|
|
assert_eq!(editor.text(cx), "fn main() { a> }");
|
|
editor.undo(&Undo, 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);
|
|
|
|
client_a.language_registry().add(rust_lang());
|
|
let mut fake_language_servers = client_a.language_registry().register_fake_lsp_adapter(
|
|
"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(
|
|
"/a",
|
|
json!({
|
|
"main.rs": "fn main() { a }",
|
|
"other.rs": "// Test file",
|
|
}),
|
|
)
|
|
.await;
|
|
let (project_a, worktree_id) = client_a.build_local_project("/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.build_dev_server_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, "main.rs"), cx))
|
|
.await
|
|
.unwrap();
|
|
let cx_b = cx_b.add_empty_window();
|
|
let editor_b = cx_b.new_view(|cx| Editor::for_buffer(buffer_b, Some(project_b.clone()), 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_view(&editor_b);
|
|
editor_b.update(cx_b, |editor, cx| {
|
|
editor.change_selections(None, cx, |s| s.select_ranges([13..13]));
|
|
editor.handle_input(":", cx);
|
|
});
|
|
|
|
// Receive an OnTypeFormatting request as the host's language server.
|
|
// Return some formatting from the host's language server.
|
|
executor.start_waiting();
|
|
fake_language_server
|
|
.handle_request::<lsp::request::OnTypeFormatting, _, _>(|params, _| async move {
|
|
assert_eq!(
|
|
params.text_document_position.text_document.uri,
|
|
lsp::Url::from_file_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();
|
|
executor.finish_waiting();
|
|
|
|
// 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, "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(cx_b, |editor, cx| {
|
|
assert_eq!(editor.text(cx), "fn main() { a:~: }");
|
|
editor.undo(&Undo, 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(cx_b, |editor, cx| {
|
|
assert_eq!(editor.text(cx), "fn main() { a: }");
|
|
editor.undo(&Undo, 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| {
|
|
cx.update_global(|store: &mut SettingsStore, cx| {
|
|
store.update_user_settings::<AllLanguageSettings>(cx, |settings| {
|
|
settings.defaults.inlay_hints = Some(InlayHintSettings {
|
|
enabled: true,
|
|
edit_debounce_ms: 0,
|
|
scroll_debounce_ms: 0,
|
|
show_type_hints: true,
|
|
show_parameter_hints: false,
|
|
show_other_hints: true,
|
|
})
|
|
});
|
|
});
|
|
});
|
|
cx_b.update(|cx| {
|
|
cx.update_global(|store: &mut SettingsStore, cx| {
|
|
store.update_user_settings::<AllLanguageSettings>(cx, |settings| {
|
|
settings.defaults.inlay_hints = Some(InlayHintSettings {
|
|
enabled: true,
|
|
edit_debounce_ms: 0,
|
|
scroll_debounce_ms: 0,
|
|
show_type_hints: true,
|
|
show_parameter_hints: false,
|
|
show_other_hints: true,
|
|
})
|
|
});
|
|
});
|
|
});
|
|
|
|
client_a.language_registry().add(rust_lang());
|
|
client_b.language_registry().add(rust_lang());
|
|
let mut fake_language_servers = client_a.language_registry().register_fake_lsp_adapter(
|
|
"Rust",
|
|
FakeLspAdapter {
|
|
capabilities: lsp::ServerCapabilities {
|
|
inlay_hint_provider: Some(lsp::OneOf::Left(true)),
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
},
|
|
);
|
|
|
|
// Client A opens a project.
|
|
client_a
|
|
.fs()
|
|
.insert_tree(
|
|
"/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("/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.build_dev_server_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);
|
|
executor.start_waiting();
|
|
|
|
// The host opens a rust file.
|
|
let _buffer_a = project_a
|
|
.update(cx_a, |project, cx| {
|
|
project.open_local_buffer("/a/main.rs", cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let fake_language_server = fake_language_servers.next().await.unwrap();
|
|
let editor_a = workspace_a
|
|
.update(cx_a, |workspace, cx| {
|
|
workspace.open_path((worktree_id, "main.rs"), None, true, cx)
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.downcast::<Editor>()
|
|
.unwrap();
|
|
|
|
// 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);
|
|
fake_language_server
|
|
.handle_request::<lsp::request::InlayHintRequest, _, _>(move |params, _| {
|
|
let task_edits_made = Arc::clone(&closure_edits_made);
|
|
async move {
|
|
assert_eq!(
|
|
params.text_document.uri,
|
|
lsp::Url::from_file_path("/a/main.rs").unwrap(),
|
|
);
|
|
let edits_made = task_edits_made.load(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,
|
|
}]))
|
|
}
|
|
})
|
|
.next()
|
|
.await
|
|
.unwrap();
|
|
|
|
executor.run_until_parked();
|
|
|
|
let initial_edit = edits_made.load(atomic::Ordering::Acquire);
|
|
editor_a.update(cx_a, |editor, _| {
|
|
assert_eq!(
|
|
vec![initial_edit.to_string()],
|
|
extract_hint_labels(editor),
|
|
"Host should get its first hints when opens an editor"
|
|
);
|
|
let inlay_cache = editor.inlay_hint_cache();
|
|
assert_eq!(
|
|
inlay_cache.version(),
|
|
1,
|
|
"Host editor update the cache version after every cache/view change",
|
|
);
|
|
});
|
|
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
|
|
let editor_b = workspace_b
|
|
.update(cx_b, |workspace, cx| {
|
|
workspace.open_path((worktree_id, "main.rs"), None, true, cx)
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.downcast::<Editor>()
|
|
.unwrap();
|
|
|
|
executor.run_until_parked();
|
|
editor_b.update(cx_b, |editor, _| {
|
|
assert_eq!(
|
|
vec![initial_edit.to_string()],
|
|
extract_hint_labels(editor),
|
|
"Client should get its first hints when opens an editor"
|
|
);
|
|
let inlay_cache = editor.inlay_hint_cache();
|
|
assert_eq!(
|
|
inlay_cache.version(),
|
|
1,
|
|
"Guest editor update the cache version after every cache/view change"
|
|
);
|
|
});
|
|
|
|
let after_client_edit = edits_made.fetch_add(1, atomic::Ordering::Release) + 1;
|
|
editor_b.update(cx_b, |editor, cx| {
|
|
editor.change_selections(None, cx, |s| s.select_ranges([13..13].clone()));
|
|
editor.handle_input(":", cx);
|
|
});
|
|
cx_b.focus_view(&editor_b);
|
|
|
|
executor.run_until_parked();
|
|
editor_a.update(cx_a, |editor, _| {
|
|
assert_eq!(
|
|
vec![after_client_edit.to_string()],
|
|
extract_hint_labels(editor),
|
|
);
|
|
let inlay_cache = editor.inlay_hint_cache();
|
|
assert_eq!(inlay_cache.version(), 2);
|
|
});
|
|
editor_b.update(cx_b, |editor, _| {
|
|
assert_eq!(
|
|
vec![after_client_edit.to_string()],
|
|
extract_hint_labels(editor),
|
|
);
|
|
let inlay_cache = editor.inlay_hint_cache();
|
|
assert_eq!(inlay_cache.version(), 2);
|
|
});
|
|
|
|
let after_host_edit = edits_made.fetch_add(1, atomic::Ordering::Release) + 1;
|
|
editor_a.update(cx_a, |editor, cx| {
|
|
editor.change_selections(None, cx, |s| s.select_ranges([13..13]));
|
|
editor.handle_input("a change to increment both buffers' versions", cx);
|
|
});
|
|
cx_a.focus_view(&editor_a);
|
|
|
|
executor.run_until_parked();
|
|
editor_a.update(cx_a, |editor, _| {
|
|
assert_eq!(
|
|
vec![after_host_edit.to_string()],
|
|
extract_hint_labels(editor),
|
|
);
|
|
let inlay_cache = editor.inlay_hint_cache();
|
|
assert_eq!(inlay_cache.version(), 3);
|
|
});
|
|
editor_b.update(cx_b, |editor, _| {
|
|
assert_eq!(
|
|
vec![after_host_edit.to_string()],
|
|
extract_hint_labels(editor),
|
|
);
|
|
let inlay_cache = editor.inlay_hint_cache();
|
|
assert_eq!(inlay_cache.version(), 3);
|
|
});
|
|
|
|
let after_special_edit_for_refresh = edits_made.fetch_add(1, atomic::Ordering::Release) + 1;
|
|
fake_language_server
|
|
.request::<lsp::request::InlayHintRefreshRequest>(())
|
|
.await
|
|
.expect("inlay refresh request failed");
|
|
|
|
executor.run_until_parked();
|
|
editor_a.update(cx_a, |editor, _| {
|
|
assert_eq!(
|
|
vec![after_special_edit_for_refresh.to_string()],
|
|
extract_hint_labels(editor),
|
|
"Host should react to /refresh LSP request"
|
|
);
|
|
let inlay_cache = editor.inlay_hint_cache();
|
|
assert_eq!(
|
|
inlay_cache.version(),
|
|
4,
|
|
"Host should accepted all edits and bump its cache version every time"
|
|
);
|
|
});
|
|
editor_b.update(cx_b, |editor, _| {
|
|
assert_eq!(
|
|
vec![after_special_edit_for_refresh.to_string()],
|
|
extract_hint_labels(editor),
|
|
"Guest should get a /refresh LSP request propagated by host"
|
|
);
|
|
let inlay_cache = editor.inlay_hint_cache();
|
|
assert_eq!(
|
|
inlay_cache.version(),
|
|
4,
|
|
"Guest should accepted all edits and bump its cache version every time"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[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| {
|
|
cx.update_global(|store: &mut SettingsStore, cx| {
|
|
store.update_user_settings::<AllLanguageSettings>(cx, |settings| {
|
|
settings.defaults.inlay_hints = Some(InlayHintSettings {
|
|
enabled: false,
|
|
edit_debounce_ms: 0,
|
|
scroll_debounce_ms: 0,
|
|
show_type_hints: false,
|
|
show_parameter_hints: false,
|
|
show_other_hints: false,
|
|
})
|
|
});
|
|
});
|
|
});
|
|
cx_b.update(|cx| {
|
|
cx.update_global(|store: &mut SettingsStore, cx| {
|
|
store.update_user_settings::<AllLanguageSettings>(cx, |settings| {
|
|
settings.defaults.inlay_hints = Some(InlayHintSettings {
|
|
enabled: true,
|
|
edit_debounce_ms: 0,
|
|
scroll_debounce_ms: 0,
|
|
show_type_hints: true,
|
|
show_parameter_hints: true,
|
|
show_other_hints: true,
|
|
})
|
|
});
|
|
});
|
|
});
|
|
|
|
client_a.language_registry().add(rust_lang());
|
|
client_b.language_registry().add(rust_lang());
|
|
let mut fake_language_servers = client_a.language_registry().register_fake_lsp_adapter(
|
|
"Rust",
|
|
FakeLspAdapter {
|
|
capabilities: lsp::ServerCapabilities {
|
|
inlay_hint_provider: Some(lsp::OneOf::Left(true)),
|
|
..Default::default()
|
|
},
|
|
..Default::default()
|
|
},
|
|
);
|
|
|
|
client_a
|
|
.fs()
|
|
.insert_tree(
|
|
"/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("/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.build_dev_server_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);
|
|
|
|
cx_a.background_executor.start_waiting();
|
|
|
|
let editor_a = workspace_a
|
|
.update(cx_a, |workspace, cx| {
|
|
workspace.open_path((worktree_id, "main.rs"), None, true, cx)
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.downcast::<Editor>()
|
|
.unwrap();
|
|
|
|
let editor_b = workspace_b
|
|
.update(cx_b, |workspace, cx| {
|
|
workspace.open_path((worktree_id, "main.rs"), None, true, 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
|
|
.handle_request::<lsp::request::InlayHintRequest, _, _>(move |params, _| {
|
|
let task_other_hints = Arc::clone(&closure_other_hints);
|
|
async move {
|
|
assert_eq!(
|
|
params.text_document.uri,
|
|
lsp::Url::from_file_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,
|
|
}]))
|
|
}
|
|
})
|
|
.next()
|
|
.await
|
|
.unwrap();
|
|
executor.finish_waiting();
|
|
|
|
executor.run_until_parked();
|
|
editor_a.update(cx_a, |editor, _| {
|
|
assert!(
|
|
extract_hint_labels(editor).is_empty(),
|
|
"Host should get no hints due to them turned off"
|
|
);
|
|
let inlay_cache = editor.inlay_hint_cache();
|
|
assert_eq!(
|
|
inlay_cache.version(),
|
|
0,
|
|
"Turned off hints should not generate version updates"
|
|
);
|
|
});
|
|
|
|
executor.run_until_parked();
|
|
editor_b.update(cx_b, |editor, _| {
|
|
assert_eq!(
|
|
vec!["initial hint".to_string()],
|
|
extract_hint_labels(editor),
|
|
"Client should get its first hints when opens an editor"
|
|
);
|
|
let inlay_cache = editor.inlay_hint_cache();
|
|
assert_eq!(
|
|
inlay_cache.version(),
|
|
1,
|
|
"Should update cache version after first hints"
|
|
);
|
|
});
|
|
|
|
other_hints.fetch_or(true, atomic::Ordering::Release);
|
|
fake_language_server
|
|
.request::<lsp::request::InlayHintRefreshRequest>(())
|
|
.await
|
|
.expect("inlay refresh request failed");
|
|
executor.run_until_parked();
|
|
editor_a.update(cx_a, |editor, _| {
|
|
assert!(
|
|
extract_hint_labels(editor).is_empty(),
|
|
"Host should get nop hints due to them turned off, even after the /refresh"
|
|
);
|
|
let inlay_cache = editor.inlay_hint_cache();
|
|
assert_eq!(
|
|
inlay_cache.version(),
|
|
0,
|
|
"Turned off hints should not generate version updates, again"
|
|
);
|
|
});
|
|
|
|
executor.run_until_parked();
|
|
editor_b.update(cx_b, |editor, _| {
|
|
assert_eq!(
|
|
vec!["other hint".to_string()],
|
|
extract_hint_labels(editor),
|
|
"Guest should get a /refresh LSP request propagated by host despite host hints are off"
|
|
);
|
|
let inlay_cache = editor.inlay_hint_cache();
|
|
assert_eq!(
|
|
inlay_cache.version(),
|
|
2,
|
|
"Guest should accepted all edits and bump its cache version every time"
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_multiple_hunk_types_revert(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());
|
|
client_b.language_registry().add(rust_lang());
|
|
|
|
let base_text = indoc! {r#"struct Row;
|
|
struct Row1;
|
|
struct Row2;
|
|
|
|
struct Row4;
|
|
struct Row5;
|
|
struct Row6;
|
|
|
|
struct Row8;
|
|
struct Row9;
|
|
struct Row10;"#};
|
|
|
|
client_a
|
|
.fs()
|
|
.insert_tree(
|
|
"/a",
|
|
json!({
|
|
"main.rs": base_text,
|
|
}),
|
|
)
|
|
.await;
|
|
let (project_a, worktree_id) = client_a.build_local_project("/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.build_dev_server_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(cx_a, |workspace, cx| {
|
|
workspace.open_path((worktree_id, "main.rs"), None, true, cx)
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.downcast::<Editor>()
|
|
.unwrap();
|
|
|
|
let editor_b = workspace_b
|
|
.update(cx_b, |workspace, cx| {
|
|
workspace.open_path((worktree_id, "main.rs"), None, true, cx)
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.downcast::<Editor>()
|
|
.unwrap();
|
|
|
|
let mut editor_cx_a = EditorTestContext {
|
|
cx: cx_a.clone(),
|
|
window: cx_a.handle(),
|
|
editor: editor_a,
|
|
assertion_cx: AssertionContextManager::new(),
|
|
};
|
|
let mut editor_cx_b = EditorTestContext {
|
|
cx: cx_b.clone(),
|
|
window: cx_b.handle(),
|
|
editor: editor_b,
|
|
assertion_cx: AssertionContextManager::new(),
|
|
};
|
|
|
|
// host edits the file, that differs from the base text, producing diff hunks
|
|
editor_cx_a.set_state(indoc! {r#"struct Row;
|
|
struct Row0.1;
|
|
struct Row0.2;
|
|
struct Row1;
|
|
|
|
struct Row4;
|
|
struct Row5444;
|
|
struct Row6;
|
|
|
|
struct Row9;
|
|
struct Row1220;ˇ"#});
|
|
editor_cx_a.update_editor(|editor, cx| {
|
|
editor
|
|
.buffer()
|
|
.read(cx)
|
|
.as_singleton()
|
|
.unwrap()
|
|
.update(cx, |buffer, cx| {
|
|
buffer.set_diff_base(Some(base_text.into()), cx);
|
|
});
|
|
});
|
|
editor_cx_b.update_editor(|editor, cx| {
|
|
editor
|
|
.buffer()
|
|
.read(cx)
|
|
.as_singleton()
|
|
.unwrap()
|
|
.update(cx, |buffer, cx| {
|
|
buffer.set_diff_base(Some(base_text.into()), cx);
|
|
});
|
|
});
|
|
cx_a.executor().run_until_parked();
|
|
cx_b.executor().run_until_parked();
|
|
|
|
// the client selects a range in the updated buffer, expands it to see the diff for each hunk in the selection
|
|
// the host does not see the diffs toggled
|
|
editor_cx_b.set_selections_state(indoc! {r#"«ˇstruct Row;
|
|
struct Row0.1;
|
|
struct Row0.2;
|
|
struct Row1;
|
|
|
|
struct Row4;
|
|
struct Row5444;
|
|
struct Row6;
|
|
|
|
struct R»ow9;
|
|
struct Row1220;"#});
|
|
editor_cx_b
|
|
.update_editor(|editor, cx| editor.toggle_hunk_diff(&editor::actions::ToggleHunkDiff, cx));
|
|
cx_a.executor().run_until_parked();
|
|
cx_b.executor().run_until_parked();
|
|
editor_cx_a.update_editor(|editor, cx| {
|
|
let snapshot = editor.snapshot(cx);
|
|
let all_hunks = editor_hunks(editor, &snapshot, cx);
|
|
let all_expanded_hunks = expanded_hunks(&editor, &snapshot, cx);
|
|
assert_eq!(
|
|
expanded_hunks_background_highlights(editor, &snapshot),
|
|
Vec::new(),
|
|
);
|
|
assert_eq!(
|
|
all_hunks,
|
|
vec![
|
|
("".to_string(), DiffHunkStatus::Added, 1..3),
|
|
("struct Row2;\n".to_string(), DiffHunkStatus::Removed, 4..4),
|
|
("struct Row5;\n".to_string(), DiffHunkStatus::Modified, 6..7),
|
|
("struct Row8;\n".to_string(), DiffHunkStatus::Removed, 9..9),
|
|
(
|
|
"struct Row10;".to_string(),
|
|
DiffHunkStatus::Modified,
|
|
10..10,
|
|
),
|
|
]
|
|
);
|
|
assert_eq!(all_expanded_hunks, Vec::new());
|
|
});
|
|
editor_cx_b.update_editor(|editor, cx| {
|
|
let snapshot = editor.snapshot(cx);
|
|
let all_hunks = editor_hunks(editor, &snapshot, cx);
|
|
let all_expanded_hunks = expanded_hunks(&editor, &snapshot, cx);
|
|
assert_eq!(
|
|
expanded_hunks_background_highlights(editor, &snapshot),
|
|
vec![1..3, 8..9],
|
|
);
|
|
assert_eq!(
|
|
all_hunks,
|
|
vec![
|
|
("".to_string(), DiffHunkStatus::Added, 1..3),
|
|
("struct Row2;\n".to_string(), DiffHunkStatus::Removed, 5..5),
|
|
("struct Row5;\n".to_string(), DiffHunkStatus::Modified, 8..9),
|
|
(
|
|
"struct Row8;\n".to_string(),
|
|
DiffHunkStatus::Removed,
|
|
12..12
|
|
),
|
|
(
|
|
"struct Row10;".to_string(),
|
|
DiffHunkStatus::Modified,
|
|
13..13,
|
|
),
|
|
]
|
|
);
|
|
assert_eq!(all_expanded_hunks, &all_hunks[..all_hunks.len() - 1]);
|
|
});
|
|
|
|
// the client reverts the hunks, removing the expanded diffs too
|
|
// both host and the client observe the reverted state (with one hunk left, not covered by client's selection)
|
|
editor_cx_b.update_editor(|editor, cx| {
|
|
editor.revert_selected_hunks(&RevertSelectedHunks, cx);
|
|
});
|
|
cx_a.executor().run_until_parked();
|
|
cx_b.executor().run_until_parked();
|
|
editor_cx_a.update_editor(|editor, cx| {
|
|
let snapshot = editor.snapshot(cx);
|
|
let all_hunks = editor_hunks(editor, &snapshot, cx);
|
|
let all_expanded_hunks = expanded_hunks(&editor, &snapshot, cx);
|
|
assert_eq!(
|
|
expanded_hunks_background_highlights(editor, &snapshot),
|
|
Vec::new(),
|
|
);
|
|
assert_eq!(
|
|
all_hunks,
|
|
vec![(
|
|
"struct Row10;".to_string(),
|
|
DiffHunkStatus::Modified,
|
|
10..10,
|
|
)]
|
|
);
|
|
assert_eq!(all_expanded_hunks, Vec::new());
|
|
});
|
|
editor_cx_b.update_editor(|editor, cx| {
|
|
let snapshot = editor.snapshot(cx);
|
|
let all_hunks = editor_hunks(editor, &snapshot, cx);
|
|
let all_expanded_hunks = expanded_hunks(&editor, &snapshot, cx);
|
|
assert_eq!(
|
|
expanded_hunks_background_highlights(editor, &snapshot),
|
|
Vec::new(),
|
|
);
|
|
assert_eq!(
|
|
all_hunks,
|
|
vec![(
|
|
"struct Row10;".to_string(),
|
|
DiffHunkStatus::Modified,
|
|
10..10,
|
|
)]
|
|
);
|
|
assert_eq!(all_expanded_hunks, Vec::new());
|
|
});
|
|
editor_cx_a.assert_editor_state(indoc! {r#"struct Row;
|
|
struct Row1;
|
|
struct Row2;
|
|
|
|
struct Row4;
|
|
struct Row5;
|
|
struct Row6;
|
|
|
|
struct Row8;
|
|
struct Row9;
|
|
struct Row1220;ˇ"#});
|
|
editor_cx_b.assert_editor_state(indoc! {r#"«ˇstruct Row;
|
|
struct Row1;
|
|
struct Row2;
|
|
|
|
struct Row4;
|
|
struct Row5;
|
|
struct Row6;
|
|
|
|
struct Row8;
|
|
struct R»ow9;
|
|
struct Row1220;"#});
|
|
}
|
|
|
|
#[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: false,
|
|
delay_ms: None,
|
|
min_column: None,
|
|
});
|
|
cx_a.update(|cx| {
|
|
cx.update_global(|store: &mut SettingsStore, cx| {
|
|
store.update_user_settings::<ProjectSettings>(cx, |settings| {
|
|
settings.git.inline_blame = inline_blame_off_settings;
|
|
});
|
|
});
|
|
});
|
|
cx_b.update(|cx| {
|
|
cx.update_global(|store: &mut SettingsStore, cx| {
|
|
store.update_user_settings::<ProjectSettings>(cx, |settings| {
|
|
settings.git.inline_blame = inline_blame_off_settings;
|
|
});
|
|
});
|
|
});
|
|
|
|
client_a
|
|
.fs()
|
|
.insert_tree(
|
|
"/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),
|
|
],
|
|
permalinks: HashMap::default(), // This field is deprecrated
|
|
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(),
|
|
remote_url: Some("git@github.com:zed-industries/zed.git".to_string()),
|
|
};
|
|
client_a.fs().set_blame_for_repo(
|
|
Path::new("/my-repo/.git"),
|
|
vec![(Path::new("file.txt"), blame)],
|
|
);
|
|
|
|
let (project_a, worktree_id) = client_a.build_local_project("/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(cx_a, |workspace, cx| {
|
|
workspace.open_path((worktree_id, "file.txt"), None, true, cx)
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.downcast::<Editor>()
|
|
.unwrap();
|
|
|
|
// Join the project as client B.
|
|
let project_b = client_b.build_dev_server_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(cx_b, |workspace, cx| {
|
|
workspace.open_path((worktree_id, "file.txt"), None, true, cx)
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.downcast::<Editor>()
|
|
.unwrap();
|
|
|
|
// client_b now requests git blame for the open buffer
|
|
editor_b.update(cx_b, |editor_b, cx| {
|
|
assert!(editor_b.blame().is_none());
|
|
editor_b.toggle_git_blame(&editor::actions::ToggleGitBlame {}, 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(Some), cx)
|
|
.collect::<Vec<_>>()
|
|
});
|
|
|
|
assert_eq!(
|
|
entries,
|
|
vec![
|
|
Some(blame_entry("1b1b1b", 0..1)),
|
|
Some(blame_entry("0d0d0d", 1..2)),
|
|
Some(blame_entry("3a3a3a", 2..3)),
|
|
Some(blame_entry("4c4c4c", 3..4)),
|
|
]
|
|
);
|
|
|
|
blame.update(cx, |blame, _| {
|
|
for (idx, entry) in entries.iter().flatten().enumerate() {
|
|
let details = blame.details_for_entry(entry).unwrap();
|
|
assert_eq!(details.message, format!("message for idx-{}", idx));
|
|
assert_eq!(
|
|
details.permalink.unwrap().to_string(),
|
|
format!("https://github.com/zed-industries/zed/commit/{}", entry.sha)
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
// editor_b updates the file, which gets sent to client_a, which updates git blame,
|
|
// which gets back to client_b.
|
|
editor_b.update(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(Some), cx)
|
|
.collect::<Vec<_>>()
|
|
});
|
|
|
|
assert_eq!(
|
|
entries,
|
|
vec![
|
|
None,
|
|
Some(blame_entry("0d0d0d", 1..2)),
|
|
Some(blame_entry("3a3a3a", 2..3)),
|
|
Some(blame_entry("4c4c4c", 3..4)),
|
|
]
|
|
);
|
|
});
|
|
|
|
// Now editor_a also updates the file
|
|
editor_a.update(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(Some), cx)
|
|
.collect::<Vec<_>>()
|
|
});
|
|
|
|
assert_eq!(
|
|
entries,
|
|
vec![
|
|
None,
|
|
None,
|
|
Some(blame_entry("3a3a3a", 2..3)),
|
|
Some(blame_entry("4c4c4c", 3..4)),
|
|
]
|
|
);
|
|
});
|
|
}
|
|
|
|
fn extract_hint_labels(editor: &Editor) -> Vec<String> {
|
|
let mut labels = Vec::new();
|
|
for hint in editor.inlay_hint_cache().hints() {
|
|
match hint.label {
|
|
project::InlayHintLabel::String(s) => labels.push(s),
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
labels
|
|
}
|
|
|
|
fn blame_entry(sha: &str, range: Range<u32>) -> git::blame::BlameEntry {
|
|
git::blame::BlameEntry {
|
|
sha: sha.parse().unwrap(),
|
|
range,
|
|
..Default::default()
|
|
}
|
|
}
|