diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e97c529faf7eaa64d21f37832f97eb31c3ade3d0..dc0b6871703177abbf8e49a73b69eba262a2f0d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,9 @@ jobs: - name: Run tests run: cargo test --workspace --no-fail-fast + + - name: Build collab binaries + run: cargo build --bins --all-features bundle: name: Bundle app diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index 47b7cac7043b43f34a4316dfcd6f46ce5ec840b6..12a6e8e5a00b4e84f517866bf3a16df9e387e62c 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -20,6 +20,7 @@ "cmd-shift-S": "workspace::SaveAs", "cmd-=": "zed::IncreaseBufferFontSize", "cmd--": "zed::DecreaseBufferFontSize", + "cmd-0": "zed::ResetBufferFontSize", "cmd-,": "zed::OpenSettings", "cmd-q": "zed::Quit", "cmd-n": "workspace::NewFile", diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index fc13d2927c856c4125fcd0f227adbc044abd0b09..c1e5d7db8c576ca388653c0662179046000a260d 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -57,6 +57,10 @@ "Delete" ], "shift-D": "vim::DeleteToEndOfLine", + "y": [ + "vim::PushOperator", + "Yank" + ], "i": [ "vim::SwitchMode", "Insert" @@ -71,8 +75,24 @@ "shift-O": "vim::InsertLineAbove", "v": [ "vim::SwitchMode", - "Visual" - ] + { + "Visual": { + "line": false + } + } + ], + "shift-V": [ + "vim::SwitchMode", + { + "Visual": { + "line": true + } + } + ], + "p": "vim::Paste", + "u": "editor::Undo", + "ctrl-r": "editor::Redo", + "ctrl-o": "pane::GoBack" } }, { @@ -104,12 +124,19 @@ "d": "vim::CurrentLine" } }, + { + "context": "Editor && vim_operator == y", + "bindings": { + "y": "vim::CurrentLine" + } + }, { "context": "Editor && vim_mode == visual", "bindings": { "c": "vim::VisualChange", "d": "vim::VisualDelete", - "x": "vim::VisualDelete" + "x": "vim::VisualDelete", + "y": "vim::VisualYank" } }, { diff --git a/crates/collab/src/bin/seed.rs b/crates/collab/src/bin/seed.rs index ee202ee4a84b128e7fcc91b03a589bb309641a86..4a3dabfc71fc112a2fe49c891200ab16cae7a4a8 100644 --- a/crates/collab/src/bin/seed.rs +++ b/crates/collab/src/bin/seed.rs @@ -1,4 +1,5 @@ use clap::Parser; +use collab::{Error, Result}; use db::{Db, PostgresDb, UserId}; use rand::prelude::*; use serde::Deserialize; @@ -32,12 +33,12 @@ async fn main() { .expect("failed to connect to postgres database"); let mut zed_users = vec![ - "nathansobo".to_string(), - "maxbrunsfeld".to_string(), - "as-cii".to_string(), - "iamnbutler".to_string(), - "gibusu".to_string(), - "Kethku".to_string(), + ("nathansobo".to_string(), Some("nathan@zed.dev")), + ("maxbrunsfeld".to_string(), Some("max@zed.dev")), + ("as-cii".to_string(), Some("antonio@zed.dev")), + ("iamnbutler".to_string(), Some("nate@zed.dev")), + ("gibusu".to_string(), Some("greg@zed.dev")), + ("Kethku".to_string(), Some("keith@zed.dev")), ]; if args.github_users { @@ -61,7 +62,7 @@ async fn main() { .json::>() .await .expect("failed to deserialize github user"); - zed_users.extend(users.iter().map(|user| user.login.clone())); + zed_users.extend(users.iter().map(|user| (user.login.clone(), None))); if let Some(last_user) = users.last() { last_user_id = Some(last_user.id); @@ -72,7 +73,7 @@ async fn main() { } let mut zed_user_ids = Vec::::new(); - for zed_user in zed_users { + for (zed_user, email) in zed_users { if let Some(user) = db .get_user_by_github_login(&zed_user) .await @@ -81,7 +82,7 @@ async fn main() { zed_user_ids.push(user.id); } else { zed_user_ids.push( - db.create_user(&zed_user, true) + db.create_user(&zed_user, email, true) .await .expect("failed to insert user"), ); diff --git a/crates/collab/src/integration_tests.rs b/crates/collab/src/integration_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..00763023cebdf72d5992fba53ddfe91c56cfe54d --- /dev/null +++ b/crates/collab/src/integration_tests.rs @@ -0,0 +1,5359 @@ +use crate::{ + db::{tests::TestDb, UserId}, + rpc::{Executor, Server, Store}, + AppState, +}; +use ::rpc::Peer; +use anyhow::anyhow; +use client::{ + self, proto, test::FakeHttpClient, Channel, ChannelDetails, ChannelList, Client, Connection, + Credentials, EstablishConnectionError, UserStore, RECEIVE_TIMEOUT, +}; +use collections::{BTreeMap, HashMap, HashSet}; +use editor::{ + self, ConfirmCodeAction, ConfirmCompletion, ConfirmRename, Editor, Input, Redo, Rename, + ToOffset, ToggleCodeActions, Undo, +}; +use futures::{channel::mpsc, Future, StreamExt as _}; +use gpui::{ + executor::{self, Deterministic}, + geometry::vector::vec2f, + ModelHandle, Task, TestAppContext, ViewHandle, +}; +use language::{ + range_to_lsp, tree_sitter_rust, Diagnostic, DiagnosticEntry, FakeLspAdapter, Language, + LanguageConfig, LanguageRegistry, OffsetRangeExt, Point, Rope, +}; +use lsp::{self, FakeLanguageServer}; +use parking_lot::Mutex; +use project::{ + fs::{FakeFs, Fs as _}, + search::SearchQuery, + worktree::WorktreeHandle, + DiagnosticSummary, Project, ProjectPath, WorktreeId, +}; +use rand::prelude::*; +use rpc::PeerId; +use serde_json::json; +use settings::Settings; +use sqlx::types::time::OffsetDateTime; +use std::{ + cell::RefCell, + env, + ops::Deref, + path::{Path, PathBuf}, + rc::Rc, + sync::{ + atomic::{AtomicBool, Ordering::SeqCst}, + Arc, + }, + time::Duration, +}; +use theme::ThemeRegistry; +use tokio::sync::RwLockReadGuard; +use workspace::{Item, SplitDirection, ToggleFollow, Workspace}; + +#[ctor::ctor] +fn init_logger() { + if std::env::var("RUST_LOG").is_ok() { + env_logger::init(); + } +} + +#[gpui::test(iterations = 10)] +async fn test_share_project( + deterministic: Arc, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, + cx_b2: &mut TestAppContext, +) { + cx_a.foreground().forbid_parking(); + let (window_b, _) = cx_b.add_window(|_| EmptyView); + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let mut client_a = server.create_client(cx_a, "user_a").await; + let mut client_b = server.create_client(cx_b, "user_b").await; + server + .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .await; + + let fs = FakeFs::new(cx_a.background()); + fs.insert_tree( + "/a", + json!({ + ".gitignore": "ignored-dir", + "a.txt": "a-contents", + "b.txt": "b-contents", + "ignored-dir": { + "c.txt": "", + "d.txt": "", + } + }), + ) + .await; + + let (project_a, worktree_id) = client_a.build_local_project(fs, "/a", cx_a).await; + let project_id = project_a.read_with(cx_a, |project, _| project.remote_id().unwrap()); + + // Join that project as client B + let client_b_peer_id = client_b.peer_id; + let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + let replica_id_b = project_b.read_with(cx_b, |project, _| { + assert_eq!( + project + .collaborators() + .get(&client_a.peer_id) + .unwrap() + .user + .github_login, + "user_a" + ); + project.replica_id() + }); + + deterministic.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); + assert_eq!(client_b_collaborator.user.github_login, "user_b"); + }); + project_b.read_with(cx_b, |project, cx| { + let worktree = project.worktrees(cx).next().unwrap().read(cx); + assert_eq!( + worktree.paths().map(AsRef::as_ref).collect::>(), + [ + 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.add_view(window_b, |cx| Editor::for_buffer(buffer_b, None, cx)); + + // TODO + // // Create a selection set as client B and see that selection set as client A. + // buffer_a + // .condition(&cx_a, |buffer, _| buffer.selection_sets().count() == 1) + // .await; + + // Edit the buffer as client B and see that edit as client A. + editor_b.update(cx_b, |editor, cx| { + editor.handle_input(&Input("ok, ".into()), cx) + }); + buffer_a + .condition(&cx_a, |buffer, _| buffer.text() == "ok, b-contents") + .await; + + // TODO + // // Remove the selection set as client B, see those selections disappear as client A. + cx_b.update(move |_| drop(editor_b)); + // buffer_a + // .condition(&cx_a, |buffer, _| buffer.selection_sets().count() == 0) + // .await; + + // Client B can join again on a different window because they are already a participant. + let client_b2 = server.create_client(cx_b2, "user_b").await; + let project_b2 = Project::remote( + project_id, + client_b2.client.clone(), + client_b2.user_store.clone(), + client_b2.language_registry.clone(), + FakeFs::new(cx_b2.background()), + &mut cx_b2.to_async(), + ) + .await + .unwrap(); + deterministic.run_until_parked(); + project_a.read_with(cx_a, |project, _| { + assert_eq!(project.collaborators().len(), 2); + }); + project_b.read_with(cx_b, |project, _| { + assert_eq!(project.collaborators().len(), 2); + }); + project_b2.read_with(cx_b2, |project, _| { + assert_eq!(project.collaborators().len(), 2); + }); + + // Dropping client B's first project removes only that from client A's collaborators. + cx_b.update(move |_| { + drop(client_b.project.take()); + drop(project_b); + }); + deterministic.run_until_parked(); + project_a.read_with(cx_a, |project, _| { + assert_eq!(project.collaborators().len(), 1); + }); + project_b2.read_with(cx_b2, |project, _| { + assert_eq!(project.collaborators().len(), 1); + }); +} + +#[gpui::test(iterations = 10)] +async fn test_unshare_project( + deterministic: Arc, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + cx_a.foreground().forbid_parking(); + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let mut client_a = server.create_client(cx_a, "user_a").await; + let mut client_b = server.create_client(cx_b, "user_b").await; + server + .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .await; + + let fs = FakeFs::new(cx_a.background()); + fs.insert_tree( + "/a", + json!({ + "a.txt": "a-contents", + "b.txt": "b-contents", + }), + ) + .await; + + let (project_a, worktree_id) = client_a.build_local_project(fs, "/a", cx_a).await; + let worktree_a = project_a.read_with(cx_a, |project, cx| project.worktrees(cx).next().unwrap()); + let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared())); + + project_b + .update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx)) + .await + .unwrap(); + + // When client B leaves the project, it gets automatically unshared. + cx_b.update(|_| { + drop(client_b.project.take()); + drop(project_b); + }); + deterministic.run_until_parked(); + assert!(worktree_a.read_with(cx_a, |tree, _| !tree.as_local().unwrap().is_shared())); + + // When client B joins again, the project gets re-shared. + let project_b2 = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared())); + project_b2 + .update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx)) + .await + .unwrap(); + + // When client A (the host) leaves, the project gets unshared and guests are notified. + cx_a.update(|_| { + drop(project_a); + client_a.project.take(); + }); + deterministic.run_until_parked(); + project_b2.read_with(cx_b, |project, _| { + assert!(project.is_read_only()); + assert!(project.collaborators().is_empty()); + }); +} + +#[gpui::test(iterations = 10)] +async fn test_host_disconnect( + deterministic: Arc, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, + cx_c: &mut TestAppContext, +) { + cx_a.foreground().forbid_parking(); + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let mut client_a = server.create_client(cx_a, "user_a").await; + let mut client_b = server.create_client(cx_b, "user_b").await; + let client_c = server.create_client(cx_c, "user_c").await; + server + .make_contacts(vec![ + (&client_a, cx_a), + (&client_b, cx_b), + (&client_c, cx_c), + ]) + .await; + + let fs = FakeFs::new(cx_a.background()); + fs.insert_tree( + "/a", + json!({ + "a.txt": "a-contents", + "b.txt": "b-contents", + }), + ) + .await; + + let (project_a, worktree_id) = client_a.build_local_project(fs, "/a", cx_a).await; + let worktree_a = project_a.read_with(cx_a, |project, cx| project.worktrees(cx).next().unwrap()); + let project_id = project_a.read_with(cx_a, |project, _| project.remote_id().unwrap()); + + let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared())); + + project_b + .update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx)) + .await + .unwrap(); + + // Request to join that project as client C + let project_c = cx_c.spawn(|mut cx| async move { + Project::remote( + project_id, + client_c.client.clone(), + client_c.user_store.clone(), + client_c.language_registry.clone(), + FakeFs::new(cx.background()), + &mut cx, + ) + .await + }); + deterministic.run_until_parked(); + + // Drop client A's connection. Collaborators should disappear and the project should not be shown as shared. + server.disconnect_client(client_a.current_user_id(cx_a)); + cx_a.foreground().advance_clock(rpc::RECEIVE_TIMEOUT); + project_a + .condition(cx_a, |project, _| project.collaborators().is_empty()) + .await; + project_a.read_with(cx_a, |project, _| assert!(!project.is_shared())); + project_b + .condition(cx_b, |project, _| project.is_read_only()) + .await; + assert!(worktree_a.read_with(cx_a, |tree, _| !tree.as_local().unwrap().is_shared())); + cx_b.update(|_| { + drop(project_b); + }); + assert!(matches!( + project_c.await.unwrap_err(), + project::JoinProjectError::HostWentOffline + )); + + // Ensure guests can still join. + let project_b2 = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared())); + project_b2 + .update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx)) + .await + .unwrap(); +} + +#[gpui::test(iterations = 10)] +async fn test_decline_join_request( + deterministic: Arc, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + cx_a.foreground().forbid_parking(); + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let mut client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .await; + + let fs = FakeFs::new(cx_a.background()); + fs.insert_tree("/a", json!({})).await; + + let (project_a, _) = client_a.build_local_project(fs, "/a", cx_a).await; + let project_id = project_a.read_with(cx_a, |project, _| project.remote_id().unwrap()); + + // Request to join that project as client B + let project_b = cx_b.spawn(|mut cx| { + let client = client_b.client.clone(); + let user_store = client_b.user_store.clone(); + let language_registry = client_b.language_registry.clone(); + async move { + Project::remote( + project_id, + client, + user_store, + language_registry, + FakeFs::new(cx.background()), + &mut cx, + ) + .await + } + }); + deterministic.run_until_parked(); + project_a.update(cx_a, |project, cx| { + project.respond_to_join_request(client_b.user_id().unwrap(), false, cx) + }); + assert!(matches!( + project_b.await.unwrap_err(), + project::JoinProjectError::HostDeclined + )); + + // Request to join the project again as client B + let project_b = cx_b.spawn(|mut cx| { + let client = client_b.client.clone(); + let user_store = client_b.user_store.clone(); + async move { + Project::remote( + project_id, + client, + user_store, + client_b.language_registry.clone(), + FakeFs::new(cx.background()), + &mut cx, + ) + .await + } + }); + + // Close the project on the host + deterministic.run_until_parked(); + cx_a.update(|_| { + drop(project_a); + client_a.project.take(); + }); + deterministic.run_until_parked(); + assert!(matches!( + project_b.await.unwrap_err(), + project::JoinProjectError::HostClosedProject + )); +} + +#[gpui::test(iterations = 10)] +async fn test_cancel_join_request( + deterministic: Arc, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + cx_a.foreground().forbid_parking(); + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let mut client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .await; + + let fs = FakeFs::new(cx_a.background()); + fs.insert_tree("/a", json!({})).await; + + let (project_a, _) = client_a.build_local_project(fs, "/a", cx_a).await; + let project_id = project_a.read_with(cx_a, |project, _| project.remote_id().unwrap()); + + let user_b = client_a + .user_store + .update(cx_a, |store, cx| { + store.fetch_user(client_b.user_id().unwrap(), cx) + }) + .await + .unwrap(); + + let project_a_events = Rc::new(RefCell::new(Vec::new())); + project_a.update(cx_a, { + let project_a_events = project_a_events.clone(); + move |_, cx| { + cx.subscribe(&cx.handle(), move |_, _, event, _| { + project_a_events.borrow_mut().push(event.clone()); + }) + .detach(); + } + }); + + // Request to join that project as client B + let project_b = cx_b.spawn(|mut cx| { + let client = client_b.client.clone(); + let user_store = client_b.user_store.clone(); + let language_registry = client_b.language_registry.clone(); + async move { + Project::remote( + project_id, + client, + user_store, + language_registry.clone(), + FakeFs::new(cx.background()), + &mut cx, + ) + .await + } + }); + deterministic.run_until_parked(); + assert_eq!( + &*project_a_events.borrow(), + &[project::Event::ContactRequestedJoin(user_b.clone())] + ); + project_a_events.borrow_mut().clear(); + + // Cancel the join request by leaving the project + client_b + .client + .send(proto::LeaveProject { project_id }) + .unwrap(); + drop(project_b); + + deterministic.run_until_parked(); + assert_eq!( + &*project_a_events.borrow(), + &[project::Event::ContactCancelledJoinRequest(user_b.clone())] + ); +} + +#[gpui::test(iterations = 10)] +async fn test_propagate_saves_and_fs_changes( + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, + cx_c: &mut TestAppContext, +) { + cx_a.foreground().forbid_parking(); + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let mut client_a = server.create_client(cx_a, "user_a").await; + let mut client_b = server.create_client(cx_b, "user_b").await; + let mut client_c = server.create_client(cx_c, "user_c").await; + server + .make_contacts(vec![ + (&client_a, cx_a), + (&client_b, cx_b), + (&client_c, cx_c), + ]) + .await; + + let fs = FakeFs::new(cx_a.background()); + fs.insert_tree( + "/a", + json!({ + "file1": "", + "file2": "" + }), + ) + .await; + + let (project_a, worktree_id) = client_a.build_local_project(fs.clone(), "/a", cx_a).await; + let worktree_a = project_a.read_with(cx_a, |p, cx| p.worktrees(cx).next().unwrap()); + + // Join that worktree as clients B and C. + let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + let project_c = client_c.build_remote_project(&project_a, cx_a, cx_c).await; + let worktree_b = project_b.read_with(cx_b, |p, cx| p.worktrees(cx).next().unwrap()); + let worktree_c = project_c.read_with(cx_c, |p, cx| p.worktrees(cx).next().unwrap()); + + // Open and edit a buffer as both guests B and C. + let buffer_b = project_b + .update(cx_b, |p, cx| p.open_buffer((worktree_id, "file1"), cx)) + .await + .unwrap(); + let buffer_c = project_c + .update(cx_c, |p, cx| p.open_buffer((worktree_id, "file1"), cx)) + .await + .unwrap(); + buffer_b.update(cx_b, |buf, cx| buf.edit([(0..0, "i-am-b, ")], cx)); + buffer_c.update(cx_c, |buf, cx| buf.edit([(0..0, "i-am-c, ")], cx)); + + // Open and edit that buffer as the host. + let buffer_a = project_a + .update(cx_a, |p, cx| p.open_buffer((worktree_id, "file1"), cx)) + .await + .unwrap(); + + buffer_a + .condition(cx_a, |buf, _| buf.text() == "i-am-c, i-am-b, ") + .await; + buffer_a.update(cx_a, |buf, cx| { + buf.edit([(buf.len()..buf.len(), "i-am-a")], cx) + }); + + // Wait for edits to propagate + buffer_a + .condition(cx_a, |buf, _| buf.text() == "i-am-c, i-am-b, i-am-a") + .await; + buffer_b + .condition(cx_b, |buf, _| buf.text() == "i-am-c, i-am-b, i-am-a") + .await; + buffer_c + .condition(cx_c, |buf, _| buf.text() == "i-am-c, i-am-b, i-am-a") + .await; + + // Edit the buffer as the host and concurrently save as guest B. + let save_b = buffer_b.update(cx_b, |buf, cx| buf.save(cx)); + buffer_a.update(cx_a, |buf, cx| buf.edit([(0..0, "hi-a, ")], cx)); + save_b.await.unwrap(); + assert_eq!( + fs.load("/a/file1".as_ref()).await.unwrap(), + "hi-a, i-am-c, i-am-b, i-am-a" + ); + buffer_a.read_with(cx_a, |buf, _| assert!(!buf.is_dirty())); + buffer_b.read_with(cx_b, |buf, _| assert!(!buf.is_dirty())); + buffer_c.condition(cx_c, |buf, _| !buf.is_dirty()).await; + + worktree_a.flush_fs_events(cx_a).await; + + // Make changes on host's file system, see those changes on guest worktrees. + fs.rename( + "/a/file1".as_ref(), + "/a/file1-renamed".as_ref(), + Default::default(), + ) + .await + .unwrap(); + + fs.rename("/a/file2".as_ref(), "/a/file3".as_ref(), Default::default()) + .await + .unwrap(); + fs.insert_file(Path::new("/a/file4"), "4".into()).await; + + worktree_a + .condition(&cx_a, |tree, _| { + tree.paths() + .map(|p| p.to_string_lossy()) + .collect::>() + == ["file1-renamed", "file3", "file4"] + }) + .await; + worktree_b + .condition(&cx_b, |tree, _| { + tree.paths() + .map(|p| p.to_string_lossy()) + .collect::>() + == ["file1-renamed", "file3", "file4"] + }) + .await; + worktree_c + .condition(&cx_c, |tree, _| { + tree.paths() + .map(|p| p.to_string_lossy()) + .collect::>() + == ["file1-renamed", "file3", "file4"] + }) + .await; + + // Ensure buffer files are updated as well. + buffer_a + .condition(&cx_a, |buf, _| { + buf.file().unwrap().path().to_str() == Some("file1-renamed") + }) + .await; + buffer_b + .condition(&cx_b, |buf, _| { + buf.file().unwrap().path().to_str() == Some("file1-renamed") + }) + .await; + buffer_c + .condition(&cx_c, |buf, _| { + buf.file().unwrap().path().to_str() == Some("file1-renamed") + }) + .await; +} + +#[gpui::test(iterations = 10)] +async fn test_fs_operations( + executor: Arc, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + executor.forbid_parking(); + let fs = FakeFs::new(cx_a.background()); + + // Connect to a server as 2 clients. + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let mut client_a = server.create_client(cx_a, "user_a").await; + let mut client_b = server.create_client(cx_b, "user_b").await; + server + .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .await; + + // Share a project as client A + fs.insert_tree( + "/dir", + json!({ + "a.txt": "a-contents", + "b.txt": "b-contents", + }), + ) + .await; + + let (project_a, worktree_id) = client_a.build_local_project(fs, "/dir", cx_a).await; + let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + + let worktree_a = project_a.read_with(cx_a, |project, cx| project.worktrees(cx).next().unwrap()); + let worktree_b = project_b.read_with(cx_b, |project, cx| project.worktrees(cx).next().unwrap()); + + let entry = project_b + .update(cx_b, |project, cx| { + project + .create_entry((worktree_id, "c.txt"), false, cx) + .unwrap() + }) + .await + .unwrap(); + worktree_a.read_with(cx_a, |worktree, _| { + assert_eq!( + worktree + .paths() + .map(|p| p.to_string_lossy()) + .collect::>(), + ["a.txt", "b.txt", "c.txt"] + ); + }); + worktree_b.read_with(cx_b, |worktree, _| { + assert_eq!( + worktree + .paths() + .map(|p| p.to_string_lossy()) + .collect::>(), + ["a.txt", "b.txt", "c.txt"] + ); + }); + + project_b + .update(cx_b, |project, cx| { + project.rename_entry(entry.id, Path::new("d.txt"), cx) + }) + .unwrap() + .await + .unwrap(); + worktree_a.read_with(cx_a, |worktree, _| { + assert_eq!( + worktree + .paths() + .map(|p| p.to_string_lossy()) + .collect::>(), + ["a.txt", "b.txt", "d.txt"] + ); + }); + worktree_b.read_with(cx_b, |worktree, _| { + assert_eq!( + worktree + .paths() + .map(|p| p.to_string_lossy()) + .collect::>(), + ["a.txt", "b.txt", "d.txt"] + ); + }); + + let dir_entry = project_b + .update(cx_b, |project, cx| { + project + .create_entry((worktree_id, "DIR"), true, cx) + .unwrap() + }) + .await + .unwrap(); + worktree_a.read_with(cx_a, |worktree, _| { + assert_eq!( + worktree + .paths() + .map(|p| p.to_string_lossy()) + .collect::>(), + ["DIR", "a.txt", "b.txt", "d.txt"] + ); + }); + worktree_b.read_with(cx_b, |worktree, _| { + assert_eq!( + worktree + .paths() + .map(|p| p.to_string_lossy()) + .collect::>(), + ["DIR", "a.txt", "b.txt", "d.txt"] + ); + }); + + project_b + .update(cx_b, |project, cx| { + project + .create_entry((worktree_id, "DIR/e.txt"), false, cx) + .unwrap() + }) + .await + .unwrap(); + project_b + .update(cx_b, |project, cx| { + project + .create_entry((worktree_id, "DIR/SUBDIR"), true, cx) + .unwrap() + }) + .await + .unwrap(); + project_b + .update(cx_b, |project, cx| { + project + .create_entry((worktree_id, "DIR/SUBDIR/f.txt"), false, cx) + .unwrap() + }) + .await + .unwrap(); + worktree_a.read_with(cx_a, |worktree, _| { + assert_eq!( + worktree + .paths() + .map(|p| p.to_string_lossy()) + .collect::>(), + [ + "DIR", + "DIR/SUBDIR", + "DIR/SUBDIR/f.txt", + "DIR/e.txt", + "a.txt", + "b.txt", + "d.txt" + ] + ); + }); + worktree_b.read_with(cx_b, |worktree, _| { + assert_eq!( + worktree + .paths() + .map(|p| p.to_string_lossy()) + .collect::>(), + [ + "DIR", + "DIR/SUBDIR", + "DIR/SUBDIR/f.txt", + "DIR/e.txt", + "a.txt", + "b.txt", + "d.txt" + ] + ); + }); + + project_b + .update(cx_b, |project, cx| { + project + .copy_entry(dir_entry.id, Path::new("DIR2"), cx) + .unwrap() + }) + .await + .unwrap(); + worktree_a.read_with(cx_a, |worktree, _| { + assert_eq!( + worktree + .paths() + .map(|p| p.to_string_lossy()) + .collect::>(), + [ + "DIR", + "DIR/SUBDIR", + "DIR/SUBDIR/f.txt", + "DIR/e.txt", + "DIR2", + "DIR2/SUBDIR", + "DIR2/SUBDIR/f.txt", + "DIR2/e.txt", + "a.txt", + "b.txt", + "d.txt" + ] + ); + }); + worktree_b.read_with(cx_b, |worktree, _| { + assert_eq!( + worktree + .paths() + .map(|p| p.to_string_lossy()) + .collect::>(), + [ + "DIR", + "DIR/SUBDIR", + "DIR/SUBDIR/f.txt", + "DIR/e.txt", + "DIR2", + "DIR2/SUBDIR", + "DIR2/SUBDIR/f.txt", + "DIR2/e.txt", + "a.txt", + "b.txt", + "d.txt" + ] + ); + }); + + project_b + .update(cx_b, |project, cx| { + project.delete_entry(dir_entry.id, cx).unwrap() + }) + .await + .unwrap(); + worktree_a.read_with(cx_a, |worktree, _| { + assert_eq!( + worktree + .paths() + .map(|p| p.to_string_lossy()) + .collect::>(), + [ + "DIR2", + "DIR2/SUBDIR", + "DIR2/SUBDIR/f.txt", + "DIR2/e.txt", + "a.txt", + "b.txt", + "d.txt" + ] + ); + }); + worktree_b.read_with(cx_b, |worktree, _| { + assert_eq!( + worktree + .paths() + .map(|p| p.to_string_lossy()) + .collect::>(), + [ + "DIR2", + "DIR2/SUBDIR", + "DIR2/SUBDIR/f.txt", + "DIR2/e.txt", + "a.txt", + "b.txt", + "d.txt" + ] + ); + }); + + project_b + .update(cx_b, |project, cx| { + project.delete_entry(entry.id, cx).unwrap() + }) + .await + .unwrap(); + worktree_a.read_with(cx_a, |worktree, _| { + assert_eq!( + worktree + .paths() + .map(|p| p.to_string_lossy()) + .collect::>(), + [ + "DIR2", + "DIR2/SUBDIR", + "DIR2/SUBDIR/f.txt", + "DIR2/e.txt", + "a.txt", + "b.txt" + ] + ); + }); + worktree_b.read_with(cx_b, |worktree, _| { + assert_eq!( + worktree + .paths() + .map(|p| p.to_string_lossy()) + .collect::>(), + [ + "DIR2", + "DIR2/SUBDIR", + "DIR2/SUBDIR/f.txt", + "DIR2/e.txt", + "a.txt", + "b.txt" + ] + ); + }); +} + +#[gpui::test(iterations = 10)] +async fn test_buffer_conflict_after_save(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { + cx_a.foreground().forbid_parking(); + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let mut client_a = server.create_client(cx_a, "user_a").await; + let mut client_b = server.create_client(cx_b, "user_b").await; + server + .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .await; + + let fs = FakeFs::new(cx_a.background()); + fs.insert_tree( + "/dir", + json!({ + "a.txt": "a-contents", + }), + ) + .await; + + let (project_a, worktree_id) = client_a.build_local_project(fs, "/dir", cx_a).await; + let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + + // 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(); + + buffer_b.update(cx_b, |buf, cx| buf.edit([(0..0, "world ")], cx)); + buffer_b.read_with(cx_b, |buf, _| { + assert!(buf.is_dirty()); + assert!(!buf.has_conflict()); + }); + + buffer_b.update(cx_b, |buf, cx| buf.save(cx)).await.unwrap(); + buffer_b + .condition(&cx_b, |buffer_b, _| !buffer_b.is_dirty()) + .await; + buffer_b.read_with(cx_b, |buf, _| { + assert!(!buf.has_conflict()); + }); + + buffer_b.update(cx_b, |buf, cx| buf.edit([(0..0, "hello ")], cx)); + buffer_b.read_with(cx_b, |buf, _| { + assert!(buf.is_dirty()); + assert!(!buf.has_conflict()); + }); +} + +#[gpui::test(iterations = 10)] +async fn test_buffer_reloading(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { + cx_a.foreground().forbid_parking(); + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let mut client_a = server.create_client(cx_a, "user_a").await; + let mut client_b = server.create_client(cx_b, "user_b").await; + server + .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .await; + + let fs = FakeFs::new(cx_a.background()); + fs.insert_tree( + "/dir", + json!({ + "a.txt": "a-contents", + }), + ) + .await; + + let (project_a, worktree_id) = client_a.build_local_project(fs.clone(), "/dir", cx_a).await; + let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + + // 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(); + buffer_b.read_with(cx_b, |buf, _| { + assert!(!buf.is_dirty()); + assert!(!buf.has_conflict()); + }); + + fs.save(Path::new("/dir/a.txt"), &"new contents".into()) + .await + .unwrap(); + buffer_b + .condition(&cx_b, |buf, _| { + buf.text() == "new contents" && !buf.is_dirty() + }) + .await; + buffer_b.read_with(cx_b, |buf, _| { + assert!(!buf.has_conflict()); + }); +} + +#[gpui::test(iterations = 10)] +async fn test_editing_while_guest_opens_buffer( + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + cx_a.foreground().forbid_parking(); + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let mut client_a = server.create_client(cx_a, "user_a").await; + let mut client_b = server.create_client(cx_b, "user_b").await; + server + .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .await; + + let fs = FakeFs::new(cx_a.background()); + fs.insert_tree( + "/dir", + json!({ + "a.txt": "a-contents", + }), + ) + .await; + + let (project_a, worktree_id) = client_a.build_local_project(fs, "/dir", cx_a).await; + let project_b = client_b.build_remote_project(&project_a, cx_a, 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(); + + // Start opening the same buffer as client B + let buffer_b = cx_b + .background() + .spawn(project_b.update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx))); + + // Edit the buffer as client A while client B is still opening it. + cx_b.background().simulate_random_delay().await; + buffer_a.update(cx_a, |buf, cx| buf.edit([(0..0, "X")], cx)); + cx_b.background().simulate_random_delay().await; + buffer_a.update(cx_a, |buf, cx| buf.edit([(1..1, "Y")], cx)); + + let text = buffer_a.read_with(cx_a, |buf, _| buf.text()); + let buffer_b = buffer_b.await.unwrap(); + buffer_b.condition(&cx_b, |buf, _| buf.text() == text).await; +} + +#[gpui::test(iterations = 10)] +async fn test_leaving_worktree_while_opening_buffer( + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + cx_a.foreground().forbid_parking(); + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let mut client_a = server.create_client(cx_a, "user_a").await; + let mut client_b = server.create_client(cx_b, "user_b").await; + server + .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .await; + + let fs = FakeFs::new(cx_a.background()); + fs.insert_tree( + "/dir", + json!({ + "a.txt": "a-contents", + }), + ) + .await; + + let (project_a, worktree_id) = client_a.build_local_project(fs, "/dir", cx_a).await; + let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + + // See that a guest has joined as client A. + project_a + .condition(&cx_a, |p, _| p.collaborators().len() == 1) + .await; + + // Begin opening a buffer as client B, but leave the project before the open completes. + let buffer_b = cx_b + .background() + .spawn(project_b.update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx))); + cx_b.update(|_| { + drop(client_b.project.take()); + drop(project_b); + }); + drop(buffer_b); + + // See that the guest has left. + project_a + .condition(&cx_a, |p, _| p.collaborators().len() == 0) + .await; +} + +#[gpui::test(iterations = 10)] +async fn test_leaving_project(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { + cx_a.foreground().forbid_parking(); + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let mut client_a = server.create_client(cx_a, "user_a").await; + let mut client_b = server.create_client(cx_b, "user_b").await; + server + .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .await; + + let fs = FakeFs::new(cx_a.background()); + fs.insert_tree( + "/a", + json!({ + "a.txt": "a-contents", + "b.txt": "b-contents", + }), + ) + .await; + + let (project_a, _) = client_a.build_local_project(fs, "/a", cx_a).await; + let _project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + + // Client A sees that a guest has joined. + project_a + .condition(cx_a, |p, _| p.collaborators().len() == 1) + .await; + + // Drop client B's connection and ensure client A observes client B leaving the project. + client_b.disconnect(&cx_b.to_async()).unwrap(); + project_a + .condition(cx_a, |p, _| p.collaborators().len() == 0) + .await; + + // Rejoin the project as client B + let _project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + + // Client A sees that a guest has re-joined. + project_a + .condition(cx_a, |p, _| p.collaborators().len() == 1) + .await; + + // Simulate connection loss for client B and ensure client A observes client B leaving the project. + client_b.wait_for_current_user(cx_b).await; + server.disconnect_client(client_b.current_user_id(cx_b)); + cx_a.foreground().advance_clock(rpc::RECEIVE_TIMEOUT); + project_a + .condition(cx_a, |p, _| p.collaborators().len() == 0) + .await; +} + +#[gpui::test(iterations = 10)] +async fn test_collaborating_with_diagnostics( + deterministic: Arc, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, + cx_c: &mut TestAppContext, +) { + deterministic.forbid_parking(); + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let mut client_a = server.create_client(cx_a, "user_a").await; + let mut client_b = server.create_client(cx_b, "user_b").await; + let mut client_c = server.create_client(cx_c, "user_c").await; + server + .make_contacts(vec![ + (&client_a, cx_a), + (&client_b, cx_b), + (&client_c, cx_c), + ]) + .await; + + // Set up a fake language server. + let mut language = Language::new( + LanguageConfig { + name: "Rust".into(), + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + Some(tree_sitter_rust::language()), + ); + let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()); + client_a.language_registry.add(Arc::new(language)); + + // Connect to a server as 2 clients. + + // Share a project as client A + let fs = FakeFs::new(cx_a.background()); + fs.insert_tree( + "/a", + json!({ + "a.rs": "let one = two", + "other.rs": "", + }), + ) + .await; + let (project_a, worktree_id) = client_a.build_local_project(fs, "/a", cx_a).await; + let project_id = project_a.update(cx_a, |p, _| p.next_remote_id()).await; + + // Cause the language server to start. + let _buffer = cx_a + .background() + .spawn(project_a.update(cx_a, |project, cx| { + project.open_buffer( + ProjectPath { + worktree_id, + path: Path::new("other.rs").into(), + }, + cx, + ) + })) + .await + .unwrap(); + + // Join the worktree as client B. + let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + + // Simulate a language server reporting errors for a file. + let mut fake_language_server = fake_language_servers.next().await.unwrap(); + fake_language_server + .receive_notification::() + .await; + fake_language_server.notify::( + lsp::PublishDiagnosticsParams { + uri: lsp::Url::from_file_path("/a/a.rs").unwrap(), + version: None, + diagnostics: vec![lsp::Diagnostic { + severity: Some(lsp::DiagnosticSeverity::ERROR), + range: lsp::Range::new(lsp::Position::new(0, 4), lsp::Position::new(0, 7)), + message: "message 1".to_string(), + ..Default::default() + }], + }, + ); + + // Wait for server to see the diagnostics update. + deterministic.run_until_parked(); + { + let store = server.store.read().await; + let project = store.project(project_id).unwrap(); + let worktree = project.worktrees.get(&worktree_id.to_proto()).unwrap(); + assert!(!worktree.diagnostic_summaries.is_empty()); + } + + // Ensure client B observes the new diagnostics. + project_b.read_with(cx_b, |project, cx| { + assert_eq!( + project.diagnostic_summaries(cx).collect::>(), + &[( + ProjectPath { + worktree_id, + path: Arc::from(Path::new("a.rs")), + }, + DiagnosticSummary { + error_count: 1, + warning_count: 0, + ..Default::default() + }, + )] + ) + }); + + // Join project as client C and observe the diagnostics. + let project_c = client_c.build_remote_project(&project_a, cx_a, cx_c).await; + project_c.read_with(cx_c, |project, cx| { + assert_eq!( + project.diagnostic_summaries(cx).collect::>(), + &[( + ProjectPath { + worktree_id, + path: Arc::from(Path::new("a.rs")), + }, + DiagnosticSummary { + error_count: 1, + warning_count: 0, + ..Default::default() + }, + )] + ) + }); + + // Simulate a language server reporting more errors for a file. + fake_language_server.notify::( + lsp::PublishDiagnosticsParams { + uri: lsp::Url::from_file_path("/a/a.rs").unwrap(), + version: None, + diagnostics: vec![ + lsp::Diagnostic { + severity: Some(lsp::DiagnosticSeverity::ERROR), + range: lsp::Range::new(lsp::Position::new(0, 4), lsp::Position::new(0, 7)), + message: "message 1".to_string(), + ..Default::default() + }, + lsp::Diagnostic { + severity: Some(lsp::DiagnosticSeverity::WARNING), + range: lsp::Range::new(lsp::Position::new(0, 10), lsp::Position::new(0, 13)), + message: "message 2".to_string(), + ..Default::default() + }, + ], + }, + ); + + // Clients B and C get the updated summaries + deterministic.run_until_parked(); + project_b.read_with(cx_b, |project, cx| { + assert_eq!( + project.diagnostic_summaries(cx).collect::>(), + [( + ProjectPath { + worktree_id, + path: Arc::from(Path::new("a.rs")), + }, + DiagnosticSummary { + error_count: 1, + warning_count: 1, + ..Default::default() + }, + )] + ); + }); + project_c.read_with(cx_c, |project, cx| { + assert_eq!( + project.diagnostic_summaries(cx).collect::>(), + [( + ProjectPath { + worktree_id, + path: Arc::from(Path::new("a.rs")), + }, + DiagnosticSummary { + error_count: 1, + warning_count: 1, + ..Default::default() + }, + )] + ); + }); + + // Open the file with the errors on client B. They should be present. + let buffer_b = cx_b + .background() + .spawn(project_b.update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.rs"), cx))) + .await + .unwrap(); + + buffer_b.read_with(cx_b, |buffer, _| { + assert_eq!( + buffer + .snapshot() + .diagnostics_in_range::<_, Point>(0..buffer.len(), false) + .map(|entry| entry) + .collect::>(), + &[ + DiagnosticEntry { + range: Point::new(0, 4)..Point::new(0, 7), + diagnostic: Diagnostic { + group_id: 0, + message: "message 1".to_string(), + severity: lsp::DiagnosticSeverity::ERROR, + is_primary: true, + ..Default::default() + } + }, + DiagnosticEntry { + range: Point::new(0, 10)..Point::new(0, 13), + diagnostic: Diagnostic { + group_id: 1, + severity: lsp::DiagnosticSeverity::WARNING, + message: "message 2".to_string(), + is_primary: true, + ..Default::default() + } + } + ] + ); + }); + + // Simulate a language server reporting no errors for a file. + fake_language_server.notify::( + lsp::PublishDiagnosticsParams { + uri: lsp::Url::from_file_path("/a/a.rs").unwrap(), + version: None, + diagnostics: vec![], + }, + ); + deterministic.run_until_parked(); + project_a.read_with(cx_a, |project, cx| { + assert_eq!(project.diagnostic_summaries(cx).collect::>(), []) + }); + project_b.read_with(cx_b, |project, cx| { + assert_eq!(project.diagnostic_summaries(cx).collect::>(), []) + }); + project_c.read_with(cx_c, |project, cx| { + assert_eq!(project.diagnostic_summaries(cx).collect::>(), []) + }); +} + +#[gpui::test(iterations = 10)] +async fn test_collaborating_with_completion(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { + cx_a.foreground().forbid_parking(); + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let mut client_a = server.create_client(cx_a, "user_a").await; + let mut client_b = server.create_client(cx_b, "user_b").await; + server + .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .await; + + // Set up a fake language server. + let mut language = Language::new( + LanguageConfig { + name: "Rust".into(), + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + Some(tree_sitter_rust::language()), + ); + let mut fake_language_servers = language.set_fake_lsp_adapter(FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string()]), + ..Default::default() + }), + ..Default::default() + }, + ..Default::default() + }); + client_a.language_registry.add(Arc::new(language)); + + let fs = FakeFs::new(cx_a.background()); + fs.insert_tree( + "/a", + json!({ + "main.rs": "fn main() { a }", + "other.rs": "", + }), + ) + .await; + + let (project_a, worktree_id) = client_a.build_local_project(fs, "/a", cx_a).await; + let project_b = client_b.build_remote_project(&project_a, cx_a, 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 (window_b, _) = cx_b.add_window(|_| EmptyView); + let editor_b = cx_b.add_view(window_b, |cx| { + Editor::for_buffer(buffer_b.clone(), Some(project_b.clone()), cx) + }); + + let fake_language_server = fake_language_servers.next().await.unwrap(); + buffer_b + .condition(&cx_b, |buffer, _| !buffer.completion_triggers().is_empty()) + .await; + + // 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(&Input(".".into()), cx); + cx.focus(&editor_b); + }); + + // Receive a completion request as the host's language server. + // Return some completions from the host's language server. + cx_a.foreground().start_waiting(); + fake_language_server + .handle_request::(|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".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.foreground().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(); + buffer_a + .condition(&cx_a, |buffer, _| buffer.text() == "fn main() { a. }") + .await; + + // Confirm a completion on the guest. + editor_b + .condition(&cx_b, |editor, _| editor.context_menu_visible()) + .await; + editor_b.update(cx_b, |editor, cx| { + 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::( + |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. + buffer_a + .condition(&cx_a, |buffer, _| { + buffer.text() == "use d::SomeTrait;\nfn main() { a.first_method() }" + }) + .await; + buffer_b + .condition(&cx_b, |buffer, _| { + buffer.text() == "use d::SomeTrait;\nfn main() { a.first_method() }" + }) + .await; +} + +#[gpui::test(iterations = 10)] +async fn test_reloading_buffer_manually(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { + cx_a.foreground().forbid_parking(); + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let mut client_a = server.create_client(cx_a, "user_a").await; + let mut client_b = server.create_client(cx_b, "user_b").await; + server + .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .await; + + let fs = FakeFs::new(cx_a.background()); + fs.insert_tree( + "/a", + json!({ + "a.rs": "let one = 1;", + }), + ) + .await; + + let (project_a, worktree_id) = client_a.build_local_project(fs.clone(), "/a", cx_a).await; + let buffer_a = project_a + .update(cx_a, |p, cx| p.open_buffer((worktree_id, "a.rs"), cx)) + .await + .unwrap(); + + let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + + let buffer_b = cx_b + .background() + .spawn(project_b.update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.rs"), cx))) + .await + .unwrap(); + buffer_b.update(cx_b, |buffer, cx| { + buffer.edit([(4..7, "six")], cx); + buffer.edit([(10..11, "6")], cx); + assert_eq!(buffer.text(), "let six = 6;"); + assert!(buffer.is_dirty()); + assert!(!buffer.has_conflict()); + }); + buffer_a + .condition(cx_a, |buffer, _| buffer.text() == "let six = 6;") + .await; + + fs.save(Path::new("/a/a.rs"), &Rope::from("let seven = 7;")) + .await + .unwrap(); + buffer_a + .condition(cx_a, |buffer, _| buffer.has_conflict()) + .await; + buffer_b + .condition(cx_b, |buffer, _| buffer.has_conflict()) + .await; + + project_b + .update(cx_b, |project, cx| { + project.reload_buffers(HashSet::from_iter([buffer_b.clone()]), true, cx) + }) + .await + .unwrap(); + buffer_a.read_with(cx_a, |buffer, _| { + assert_eq!(buffer.text(), "let seven = 7;"); + assert!(!buffer.is_dirty()); + assert!(!buffer.has_conflict()); + }); + buffer_b.read_with(cx_b, |buffer, _| { + assert_eq!(buffer.text(), "let seven = 7;"); + assert!(!buffer.is_dirty()); + assert!(!buffer.has_conflict()); + }); + + buffer_a.update(cx_a, |buffer, cx| { + // Undoing on the host is a no-op when the reload was initiated by the guest. + buffer.undo(cx); + assert_eq!(buffer.text(), "let seven = 7;"); + assert!(!buffer.is_dirty()); + assert!(!buffer.has_conflict()); + }); + buffer_b.update(cx_b, |buffer, cx| { + // Undoing on the guest rolls back the buffer to before it was reloaded but the conflict gets cleared. + buffer.undo(cx); + assert_eq!(buffer.text(), "let six = 6;"); + assert!(buffer.is_dirty()); + assert!(!buffer.has_conflict()); + }); +} + +#[gpui::test(iterations = 10)] +async fn test_formatting_buffer(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { + cx_a.foreground().forbid_parking(); + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let mut client_a = server.create_client(cx_a, "user_a").await; + let mut client_b = server.create_client(cx_b, "user_b").await; + server + .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .await; + + // Set up a fake language server. + let mut language = Language::new( + LanguageConfig { + name: "Rust".into(), + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + Some(tree_sitter_rust::language()), + ); + let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()); + client_a.language_registry.add(Arc::new(language)); + + let fs = FakeFs::new(cx_a.background()); + fs.insert_tree( + "/a", + json!({ + "a.rs": "let one = two", + }), + ) + .await; + + let (project_a, worktree_id) = client_a.build_local_project(fs, "/a", cx_a).await; + let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + + let buffer_b = cx_b + .background() + .spawn(project_b.update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.rs"), cx))) + .await + .unwrap(); + + let fake_language_server = fake_language_servers.next().await.unwrap(); + fake_language_server.handle_request::(|_, _| async move { + Ok(Some(vec![ + lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(0, 4), lsp::Position::new(0, 4)), + new_text: "h".to_string(), + }, + lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(0, 7), lsp::Position::new(0, 7)), + new_text: "y".to_string(), + }, + ])) + }); + + project_b + .update(cx_b, |project, cx| { + project.format(HashSet::from_iter([buffer_b.clone()]), true, cx) + }) + .await + .unwrap(); + assert_eq!( + buffer_b.read_with(cx_b, |buffer, _| buffer.text()), + "let honey = two" + ); +} + +#[gpui::test(iterations = 10)] +async fn test_definition(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { + cx_a.foreground().forbid_parking(); + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let mut client_a = server.create_client(cx_a, "user_a").await; + let mut client_b = server.create_client(cx_b, "user_b").await; + server + .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .await; + + let fs = FakeFs::new(cx_a.background()); + fs.insert_tree( + "/root-1", + json!({ + "a.rs": "const ONE: usize = b::TWO + b::THREE;", + }), + ) + .await; + fs.insert_tree( + "/root-2", + json!({ + "b.rs": "const TWO: usize = 2;\nconst THREE: usize = 3;", + }), + ) + .await; + + // Set up a fake language server. + let mut language = Language::new( + LanguageConfig { + name: "Rust".into(), + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + Some(tree_sitter_rust::language()), + ); + let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()); + client_a.language_registry.add(Arc::new(language)); + + let (project_a, worktree_id) = client_a.build_local_project(fs, "/root-1", cx_a).await; + let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + + // Open the file on client B. + let buffer_b = cx_b + .background() + .spawn(project_b.update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.rs"), cx))) + .await + .unwrap(); + + // Request the definition of a symbol as the guest. + let fake_language_server = fake_language_servers.next().await.unwrap(); + fake_language_server.handle_request::(|_, _| async move { + Ok(Some(lsp::GotoDefinitionResponse::Scalar( + lsp::Location::new( + lsp::Url::from_file_path("/root-2/b.rs").unwrap(), + lsp::Range::new(lsp::Position::new(0, 6), lsp::Position::new(0, 9)), + ), + ))) + }); + + let definitions_1 = project_b + .update(cx_b, |p, cx| p.definition(&buffer_b, 23, cx)) + .await + .unwrap(); + cx_b.read(|cx| { + assert_eq!(definitions_1.len(), 1); + assert_eq!(project_b.read(cx).worktrees(cx).count(), 2); + let target_buffer = definitions_1[0].buffer.read(cx); + assert_eq!( + target_buffer.text(), + "const TWO: usize = 2;\nconst THREE: usize = 3;" + ); + assert_eq!( + definitions_1[0].range.to_point(target_buffer), + Point::new(0, 6)..Point::new(0, 9) + ); + }); + + // Try getting more definitions for the same buffer, ensuring the buffer gets reused from + // the previous call to `definition`. + fake_language_server.handle_request::(|_, _| async move { + Ok(Some(lsp::GotoDefinitionResponse::Scalar( + lsp::Location::new( + lsp::Url::from_file_path("/root-2/b.rs").unwrap(), + lsp::Range::new(lsp::Position::new(1, 6), lsp::Position::new(1, 11)), + ), + ))) + }); + + let definitions_2 = project_b + .update(cx_b, |p, cx| p.definition(&buffer_b, 33, cx)) + .await + .unwrap(); + cx_b.read(|cx| { + assert_eq!(definitions_2.len(), 1); + assert_eq!(project_b.read(cx).worktrees(cx).count(), 2); + let target_buffer = definitions_2[0].buffer.read(cx); + assert_eq!( + target_buffer.text(), + "const TWO: usize = 2;\nconst THREE: usize = 3;" + ); + assert_eq!( + definitions_2[0].range.to_point(target_buffer), + Point::new(1, 6)..Point::new(1, 11) + ); + }); + assert_eq!(definitions_1[0].buffer, definitions_2[0].buffer); +} + +#[gpui::test(iterations = 10)] +async fn test_references(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { + cx_a.foreground().forbid_parking(); + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let mut client_a = server.create_client(cx_a, "user_a").await; + let mut client_b = server.create_client(cx_b, "user_b").await; + server + .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .await; + + let fs = FakeFs::new(cx_a.background()); + fs.insert_tree( + "/root-1", + json!({ + "one.rs": "const ONE: usize = 1;", + "two.rs": "const TWO: usize = one::ONE + one::ONE;", + }), + ) + .await; + fs.insert_tree( + "/root-2", + json!({ + "three.rs": "const THREE: usize = two::TWO + one::ONE;", + }), + ) + .await; + + // Set up a fake language server. + let mut language = Language::new( + LanguageConfig { + name: "Rust".into(), + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + Some(tree_sitter_rust::language()), + ); + let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()); + client_a.language_registry.add(Arc::new(language)); + + let (project_a, worktree_id) = client_a.build_local_project(fs, "/root-1", cx_a).await; + let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + + // Open the file on client B. + let buffer_b = cx_b + .background() + .spawn(project_b.update(cx_b, |p, cx| p.open_buffer((worktree_id, "one.rs"), cx))) + .await + .unwrap(); + + // Request references to a symbol as the guest. + let fake_language_server = fake_language_servers.next().await.unwrap(); + fake_language_server.handle_request::(|params, _| async move { + assert_eq!( + params.text_document_position.text_document.uri.as_str(), + "file:///root-1/one.rs" + ); + Ok(Some(vec![ + lsp::Location { + uri: lsp::Url::from_file_path("/root-1/two.rs").unwrap(), + range: lsp::Range::new(lsp::Position::new(0, 24), lsp::Position::new(0, 27)), + }, + lsp::Location { + uri: lsp::Url::from_file_path("/root-1/two.rs").unwrap(), + range: lsp::Range::new(lsp::Position::new(0, 35), lsp::Position::new(0, 38)), + }, + lsp::Location { + uri: lsp::Url::from_file_path("/root-2/three.rs").unwrap(), + range: lsp::Range::new(lsp::Position::new(0, 37), lsp::Position::new(0, 40)), + }, + ])) + }); + + let references = project_b + .update(cx_b, |p, cx| p.references(&buffer_b, 7, cx)) + .await + .unwrap(); + cx_b.read(|cx| { + assert_eq!(references.len(), 3); + assert_eq!(project_b.read(cx).worktrees(cx).count(), 2); + + let two_buffer = references[0].buffer.read(cx); + let three_buffer = references[2].buffer.read(cx); + assert_eq!( + two_buffer.file().unwrap().path().as_ref(), + Path::new("two.rs") + ); + assert_eq!(references[1].buffer, references[0].buffer); + assert_eq!( + three_buffer.file().unwrap().full_path(cx), + Path::new("three.rs") + ); + + assert_eq!(references[0].range.to_offset(&two_buffer), 24..27); + assert_eq!(references[1].range.to_offset(&two_buffer), 35..38); + assert_eq!(references[2].range.to_offset(&three_buffer), 37..40); + }); +} + +#[gpui::test(iterations = 10)] +async fn test_project_search(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { + cx_a.foreground().forbid_parking(); + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let mut client_a = server.create_client(cx_a, "user_a").await; + let mut client_b = server.create_client(cx_b, "user_b").await; + server + .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .await; + + let fs = FakeFs::new(cx_a.background()); + fs.insert_tree( + "/root-1", + json!({ + "a": "hello world", + "b": "goodnight moon", + "c": "a world of goo", + "d": "world champion of clown world", + }), + ) + .await; + fs.insert_tree( + "/root-2", + json!({ + "e": "disney world is fun", + }), + ) + .await; + + let (project_a, _) = client_a.build_local_project(fs, "/root-1", cx_a).await; + let (worktree_2, _) = project_a + .update(cx_a, |p, cx| { + p.find_or_create_local_worktree("/root-2", true, cx) + }) + .await + .unwrap(); + worktree_2 + .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete()) + .await; + + let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + + // Perform a search as the guest. + let results = project_b + .update(cx_b, |project, cx| { + project.search(SearchQuery::text("world", false, false), cx) + }) + .await + .unwrap(); + + let mut ranges_by_path = results + .into_iter() + .map(|(buffer, ranges)| { + buffer.read_with(cx_b, |buffer, cx| { + let path = buffer.file().unwrap().full_path(cx); + let offset_ranges = ranges + .into_iter() + .map(|range| range.to_offset(buffer)) + .collect::>(); + (path, offset_ranges) + }) + }) + .collect::>(); + ranges_by_path.sort_by_key(|(path, _)| path.clone()); + + assert_eq!( + ranges_by_path, + &[ + (PathBuf::from("root-1/a"), vec![6..11]), + (PathBuf::from("root-1/c"), vec![2..7]), + (PathBuf::from("root-1/d"), vec![0..5, 24..29]), + (PathBuf::from("root-2/e"), vec![7..12]), + ] + ); +} + +#[gpui::test(iterations = 10)] +async fn test_document_highlights(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { + cx_a.foreground().forbid_parking(); + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let mut client_a = server.create_client(cx_a, "user_a").await; + let mut client_b = server.create_client(cx_b, "user_b").await; + server + .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .await; + + let fs = FakeFs::new(cx_a.background()); + fs.insert_tree( + "/root-1", + json!({ + "main.rs": "fn double(number: i32) -> i32 { number + number }", + }), + ) + .await; + + // Set up a fake language server. + let mut language = Language::new( + LanguageConfig { + name: "Rust".into(), + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + Some(tree_sitter_rust::language()), + ); + let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()); + client_a.language_registry.add(Arc::new(language)); + + let (project_a, worktree_id) = client_a.build_local_project(fs, "/root-1", cx_a).await; + let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + + // Open the file on client B. + let buffer_b = cx_b + .background() + .spawn(project_b.update(cx_b, |p, cx| p.open_buffer((worktree_id, "main.rs"), cx))) + .await + .unwrap(); + + // Request document highlights as the guest. + let fake_language_server = fake_language_servers.next().await.unwrap(); + fake_language_server.handle_request::( + |params, _| async move { + assert_eq!( + params + .text_document_position_params + .text_document + .uri + .as_str(), + "file:///root-1/main.rs" + ); + assert_eq!( + params.text_document_position_params.position, + lsp::Position::new(0, 34) + ); + Ok(Some(vec![ + lsp::DocumentHighlight { + kind: Some(lsp::DocumentHighlightKind::WRITE), + range: lsp::Range::new(lsp::Position::new(0, 10), lsp::Position::new(0, 16)), + }, + lsp::DocumentHighlight { + kind: Some(lsp::DocumentHighlightKind::READ), + range: lsp::Range::new(lsp::Position::new(0, 32), lsp::Position::new(0, 38)), + }, + lsp::DocumentHighlight { + kind: Some(lsp::DocumentHighlightKind::READ), + range: lsp::Range::new(lsp::Position::new(0, 41), lsp::Position::new(0, 47)), + }, + ])) + }, + ); + + let highlights = project_b + .update(cx_b, |p, cx| p.document_highlights(&buffer_b, 34, cx)) + .await + .unwrap(); + buffer_b.read_with(cx_b, |buffer, _| { + let snapshot = buffer.snapshot(); + + let highlights = highlights + .into_iter() + .map(|highlight| (highlight.kind, highlight.range.to_offset(&snapshot))) + .collect::>(); + assert_eq!( + highlights, + &[ + (lsp::DocumentHighlightKind::WRITE, 10..16), + (lsp::DocumentHighlightKind::READ, 32..38), + (lsp::DocumentHighlightKind::READ, 41..47) + ] + ) + }); +} + +#[gpui::test(iterations = 10)] +async fn test_project_symbols(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { + cx_a.foreground().forbid_parking(); + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let mut client_a = server.create_client(cx_a, "user_a").await; + let mut client_b = server.create_client(cx_b, "user_b").await; + server + .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .await; + + // Set up a fake language server. + let mut language = Language::new( + LanguageConfig { + name: "Rust".into(), + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + Some(tree_sitter_rust::language()), + ); + let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()); + client_a.language_registry.add(Arc::new(language)); + + let fs = FakeFs::new(cx_a.background()); + fs.insert_tree( + "/code", + json!({ + "crate-1": { + "one.rs": "const ONE: usize = 1;", + }, + "crate-2": { + "two.rs": "const TWO: usize = 2; const THREE: usize = 3;", + }, + "private": { + "passwords.txt": "the-password", + } + }), + ) + .await; + + let (project_a, worktree_id) = client_a + .build_local_project(fs, "/code/crate-1", cx_a) + .await; + let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + + // Cause the language server to start. + let _buffer = cx_b + .background() + .spawn(project_b.update(cx_b, |p, cx| p.open_buffer((worktree_id, "one.rs"), cx))) + .await + .unwrap(); + + let fake_language_server = fake_language_servers.next().await.unwrap(); + fake_language_server.handle_request::(|_, _| async move { + #[allow(deprecated)] + Ok(Some(vec![lsp::SymbolInformation { + name: "TWO".into(), + location: lsp::Location { + uri: lsp::Url::from_file_path("/code/crate-2/two.rs").unwrap(), + range: lsp::Range::new(lsp::Position::new(0, 6), lsp::Position::new(0, 9)), + }, + kind: lsp::SymbolKind::CONSTANT, + tags: None, + container_name: None, + deprecated: None, + }])) + }); + + // Request the definition of a symbol as the guest. + let symbols = project_b + .update(cx_b, |p, cx| p.symbols("two", cx)) + .await + .unwrap(); + assert_eq!(symbols.len(), 1); + assert_eq!(symbols[0].name, "TWO"); + + // Open one of the returned symbols. + let buffer_b_2 = project_b + .update(cx_b, |project, cx| { + project.open_buffer_for_symbol(&symbols[0], cx) + }) + .await + .unwrap(); + buffer_b_2.read_with(cx_b, |buffer, _| { + assert_eq!( + buffer.file().unwrap().path().as_ref(), + Path::new("../crate-2/two.rs") + ); + }); + + // Attempt to craft a symbol and violate host's privacy by opening an arbitrary file. + let mut fake_symbol = symbols[0].clone(); + fake_symbol.path = Path::new("/code/secrets").into(); + let error = project_b + .update(cx_b, |project, cx| { + project.open_buffer_for_symbol(&fake_symbol, cx) + }) + .await + .unwrap_err(); + assert!(error.to_string().contains("invalid symbol signature")); +} + +#[gpui::test(iterations = 10)] +async fn test_open_buffer_while_getting_definition_pointing_to_it( + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, + mut rng: StdRng, +) { + cx_a.foreground().forbid_parking(); + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let mut client_a = server.create_client(cx_a, "user_a").await; + let mut client_b = server.create_client(cx_b, "user_b").await; + server + .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .await; + + // Set up a fake language server. + let mut language = Language::new( + LanguageConfig { + name: "Rust".into(), + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + Some(tree_sitter_rust::language()), + ); + let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()); + client_a.language_registry.add(Arc::new(language)); + + let fs = FakeFs::new(cx_a.background()); + fs.insert_tree( + "/root", + json!({ + "a.rs": "const ONE: usize = b::TWO;", + "b.rs": "const TWO: usize = 2", + }), + ) + .await; + + let (project_a, worktree_id) = client_a.build_local_project(fs, "/root", cx_a).await; + let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + + let buffer_b1 = cx_b + .background() + .spawn(project_b.update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.rs"), cx))) + .await + .unwrap(); + + let fake_language_server = fake_language_servers.next().await.unwrap(); + fake_language_server.handle_request::(|_, _| async move { + Ok(Some(lsp::GotoDefinitionResponse::Scalar( + lsp::Location::new( + lsp::Url::from_file_path("/root/b.rs").unwrap(), + lsp::Range::new(lsp::Position::new(0, 6), lsp::Position::new(0, 9)), + ), + ))) + }); + + let definitions; + let buffer_b2; + if rng.gen() { + definitions = project_b.update(cx_b, |p, cx| p.definition(&buffer_b1, 23, cx)); + buffer_b2 = project_b.update(cx_b, |p, cx| p.open_buffer((worktree_id, "b.rs"), cx)); + } else { + buffer_b2 = project_b.update(cx_b, |p, cx| p.open_buffer((worktree_id, "b.rs"), cx)); + definitions = project_b.update(cx_b, |p, cx| p.definition(&buffer_b1, 23, cx)); + } + + let buffer_b2 = buffer_b2.await.unwrap(); + let definitions = definitions.await.unwrap(); + assert_eq!(definitions.len(), 1); + assert_eq!(definitions[0].buffer, buffer_b2); +} + +#[gpui::test(iterations = 10)] +async fn test_collaborating_with_code_actions( + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + cx_a.foreground().forbid_parking(); + cx_b.update(|cx| editor::init(cx)); + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let mut client_a = server.create_client(cx_a, "user_a").await; + let mut client_b = server.create_client(cx_b, "user_b").await; + server + .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .await; + + // Set up a fake language server. + let mut language = Language::new( + LanguageConfig { + name: "Rust".into(), + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + Some(tree_sitter_rust::language()), + ); + let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()); + client_a.language_registry.add(Arc::new(language)); + + let fs = FakeFs::new(cx_a.background()); + 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(fs, "/a", cx_a).await; + + // Join the project as client B. + let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + let (_window_b, workspace_b) = cx_b.add_window(|cx| Workspace::new(project_b.clone(), cx)); + let editor_b = workspace_b + .update(cx_b, |workspace, cx| { + workspace.open_path((worktree_id, "main.rs"), true, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + let mut fake_language_server = fake_language_servers.next().await.unwrap(); + fake_language_server + .handle_request::(|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) + }) + .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.focus(&editor_b); + }); + + fake_language_server + .handle_request::(|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() + }, + )])) + }) + .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, + ); + }); + editor_b + .condition(&cx_b, |editor, _| editor.context_menu_visible()) + .await; + + fake_language_server.remove_request_handler::(); + + // Confirming the code action will trigger a resolve request. + let confirm_action = workspace_b + .update(cx_b, |workspace, cx| { + Editor::confirm_code_action(workspace, &ConfirmCodeAction { item_ix: Some(0) }, cx) + }) + .unwrap(); + fake_language_server.handle_request::( + |_, _| 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.read_with(cx_b, |workspace, cx| { + workspace + .active_item(cx) + .unwrap() + .downcast::() + .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) { + cx_a.foreground().forbid_parking(); + cx_b.update(|cx| editor::init(cx)); + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let mut client_a = server.create_client(cx_a, "user_a").await; + let mut client_b = server.create_client(cx_b, "user_b").await; + server + .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .await; + + // Set up a fake language server. + let mut language = Language::new( + LanguageConfig { + name: "Rust".into(), + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + Some(tree_sitter_rust::language()), + ); + let mut fake_language_servers = language.set_fake_lsp_adapter(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.language_registry.add(Arc::new(language)); + + let fs = FakeFs::new(cx_a.background()); + 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(fs, "/dir", cx_a).await; + let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + + let (_window_b, workspace_b) = cx_b.add_window(|cx| Workspace::new(project_b.clone(), cx)); + let editor_b = workspace_b + .update(cx_b, |workspace, cx| { + workspace.open_path((worktree_id, "one.rs"), true, cx) + }) + .await + .unwrap() + .downcast::() + .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::(|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| { + 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| { + rename_editor.buffer().update(cx, |rename_buffer, cx| { + rename_buffer.edit([(0..3, "THREE")], cx); + }); + }); + }); + + let confirm_rename = workspace_b.update(cx_b, |workspace, cx| { + Editor::confirm_rename(workspace, &ConfirmRename, cx).unwrap() + }); + fake_language_server + .handle_request::(|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.read_with(cx_b, |workspace, cx| { + workspace + .active_item(cx) + .unwrap() + .downcast::() + .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_basic_chat(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { + cx_a.foreground().forbid_parking(); + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + + // Create an org that includes these 2 users. + let db = &server.app_state.db; + let org_id = db.create_org("Test Org", "test-org").await.unwrap(); + db.add_org_member(org_id, client_a.current_user_id(&cx_a), false) + .await + .unwrap(); + db.add_org_member(org_id, client_b.current_user_id(&cx_b), false) + .await + .unwrap(); + + // Create a channel that includes all the users. + let channel_id = db.create_org_channel(org_id, "test-channel").await.unwrap(); + db.add_channel_member(channel_id, client_a.current_user_id(&cx_a), false) + .await + .unwrap(); + db.add_channel_member(channel_id, client_b.current_user_id(&cx_b), false) + .await + .unwrap(); + db.create_channel_message( + channel_id, + client_b.current_user_id(&cx_b), + "hello A, it's B.", + OffsetDateTime::now_utc(), + 1, + ) + .await + .unwrap(); + + let channels_a = + cx_a.add_model(|cx| ChannelList::new(client_a.user_store.clone(), client_a.clone(), cx)); + channels_a + .condition(cx_a, |list, _| list.available_channels().is_some()) + .await; + channels_a.read_with(cx_a, |list, _| { + assert_eq!( + list.available_channels().unwrap(), + &[ChannelDetails { + id: channel_id.to_proto(), + name: "test-channel".to_string() + }] + ) + }); + let channel_a = channels_a.update(cx_a, |this, cx| { + this.get_channel(channel_id.to_proto(), cx).unwrap() + }); + channel_a.read_with(cx_a, |channel, _| assert!(channel.messages().is_empty())); + channel_a + .condition(&cx_a, |channel, _| { + channel_messages(channel) + == [("user_b".to_string(), "hello A, it's B.".to_string(), false)] + }) + .await; + + let channels_b = + cx_b.add_model(|cx| ChannelList::new(client_b.user_store.clone(), client_b.clone(), cx)); + channels_b + .condition(cx_b, |list, _| list.available_channels().is_some()) + .await; + channels_b.read_with(cx_b, |list, _| { + assert_eq!( + list.available_channels().unwrap(), + &[ChannelDetails { + id: channel_id.to_proto(), + name: "test-channel".to_string() + }] + ) + }); + + let channel_b = channels_b.update(cx_b, |this, cx| { + this.get_channel(channel_id.to_proto(), cx).unwrap() + }); + channel_b.read_with(cx_b, |channel, _| assert!(channel.messages().is_empty())); + channel_b + .condition(&cx_b, |channel, _| { + channel_messages(channel) + == [("user_b".to_string(), "hello A, it's B.".to_string(), false)] + }) + .await; + + channel_a + .update(cx_a, |channel, cx| { + channel + .send_message("oh, hi B.".to_string(), cx) + .unwrap() + .detach(); + let task = channel.send_message("sup".to_string(), cx).unwrap(); + assert_eq!( + channel_messages(channel), + &[ + ("user_b".to_string(), "hello A, it's B.".to_string(), false), + ("user_a".to_string(), "oh, hi B.".to_string(), true), + ("user_a".to_string(), "sup".to_string(), true) + ] + ); + task + }) + .await + .unwrap(); + + channel_b + .condition(&cx_b, |channel, _| { + channel_messages(channel) + == [ + ("user_b".to_string(), "hello A, it's B.".to_string(), false), + ("user_a".to_string(), "oh, hi B.".to_string(), false), + ("user_a".to_string(), "sup".to_string(), false), + ] + }) + .await; + + assert_eq!( + server + .state() + .await + .channel(channel_id) + .unwrap() + .connection_ids + .len(), + 2 + ); + cx_b.update(|_| drop(channel_b)); + server + .condition(|state| state.channel(channel_id).unwrap().connection_ids.len() == 1) + .await; + + cx_a.update(|_| drop(channel_a)); + server + .condition(|state| state.channel(channel_id).is_none()) + .await; +} + +#[gpui::test(iterations = 10)] +async fn test_chat_message_validation(cx_a: &mut TestAppContext) { + cx_a.foreground().forbid_parking(); + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let client_a = server.create_client(cx_a, "user_a").await; + + let db = &server.app_state.db; + let org_id = db.create_org("Test Org", "test-org").await.unwrap(); + let channel_id = db.create_org_channel(org_id, "test-channel").await.unwrap(); + db.add_org_member(org_id, client_a.current_user_id(&cx_a), false) + .await + .unwrap(); + db.add_channel_member(channel_id, client_a.current_user_id(&cx_a), false) + .await + .unwrap(); + + let channels_a = + cx_a.add_model(|cx| ChannelList::new(client_a.user_store.clone(), client_a.clone(), cx)); + channels_a + .condition(cx_a, |list, _| list.available_channels().is_some()) + .await; + let channel_a = channels_a.update(cx_a, |this, cx| { + this.get_channel(channel_id.to_proto(), cx).unwrap() + }); + + // Messages aren't allowed to be too long. + channel_a + .update(cx_a, |channel, cx| { + let long_body = "this is long.\n".repeat(1024); + channel.send_message(long_body, cx).unwrap() + }) + .await + .unwrap_err(); + + // Messages aren't allowed to be blank. + channel_a.update(cx_a, |channel, cx| { + channel.send_message(String::new(), cx).unwrap_err() + }); + + // Leading and trailing whitespace are trimmed. + channel_a + .update(cx_a, |channel, cx| { + channel + .send_message("\n surrounded by whitespace \n".to_string(), cx) + .unwrap() + }) + .await + .unwrap(); + assert_eq!( + db.get_channel_messages(channel_id, 10, None) + .await + .unwrap() + .iter() + .map(|m| &m.body) + .collect::>(), + &["surrounded by whitespace"] + ); +} + +#[gpui::test(iterations = 10)] +async fn test_chat_reconnection(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { + cx_a.foreground().forbid_parking(); + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + + let mut status_b = client_b.status(); + + // Create an org that includes these 2 users. + let db = &server.app_state.db; + let org_id = db.create_org("Test Org", "test-org").await.unwrap(); + db.add_org_member(org_id, client_a.current_user_id(&cx_a), false) + .await + .unwrap(); + db.add_org_member(org_id, client_b.current_user_id(&cx_b), false) + .await + .unwrap(); + + // Create a channel that includes all the users. + let channel_id = db.create_org_channel(org_id, "test-channel").await.unwrap(); + db.add_channel_member(channel_id, client_a.current_user_id(&cx_a), false) + .await + .unwrap(); + db.add_channel_member(channel_id, client_b.current_user_id(&cx_b), false) + .await + .unwrap(); + db.create_channel_message( + channel_id, + client_b.current_user_id(&cx_b), + "hello A, it's B.", + OffsetDateTime::now_utc(), + 2, + ) + .await + .unwrap(); + + let channels_a = + cx_a.add_model(|cx| ChannelList::new(client_a.user_store.clone(), client_a.clone(), cx)); + channels_a + .condition(cx_a, |list, _| list.available_channels().is_some()) + .await; + + channels_a.read_with(cx_a, |list, _| { + assert_eq!( + list.available_channels().unwrap(), + &[ChannelDetails { + id: channel_id.to_proto(), + name: "test-channel".to_string() + }] + ) + }); + let channel_a = channels_a.update(cx_a, |this, cx| { + this.get_channel(channel_id.to_proto(), cx).unwrap() + }); + channel_a.read_with(cx_a, |channel, _| assert!(channel.messages().is_empty())); + channel_a + .condition(&cx_a, |channel, _| { + channel_messages(channel) + == [("user_b".to_string(), "hello A, it's B.".to_string(), false)] + }) + .await; + + let channels_b = + cx_b.add_model(|cx| ChannelList::new(client_b.user_store.clone(), client_b.clone(), cx)); + channels_b + .condition(cx_b, |list, _| list.available_channels().is_some()) + .await; + channels_b.read_with(cx_b, |list, _| { + assert_eq!( + list.available_channels().unwrap(), + &[ChannelDetails { + id: channel_id.to_proto(), + name: "test-channel".to_string() + }] + ) + }); + + let channel_b = channels_b.update(cx_b, |this, cx| { + this.get_channel(channel_id.to_proto(), cx).unwrap() + }); + channel_b.read_with(cx_b, |channel, _| assert!(channel.messages().is_empty())); + channel_b + .condition(&cx_b, |channel, _| { + channel_messages(channel) + == [("user_b".to_string(), "hello A, it's B.".to_string(), false)] + }) + .await; + + // Disconnect client B, ensuring we can still access its cached channel data. + server.forbid_connections(); + server.disconnect_client(client_b.current_user_id(&cx_b)); + cx_b.foreground().advance_clock(rpc::RECEIVE_TIMEOUT); + while !matches!( + status_b.next().await, + Some(client::Status::ReconnectionError { .. }) + ) {} + + channels_b.read_with(cx_b, |channels, _| { + assert_eq!( + channels.available_channels().unwrap(), + [ChannelDetails { + id: channel_id.to_proto(), + name: "test-channel".to_string() + }] + ) + }); + channel_b.read_with(cx_b, |channel, _| { + assert_eq!( + channel_messages(channel), + [("user_b".to_string(), "hello A, it's B.".to_string(), false)] + ) + }); + + // Send a message from client B while it is disconnected. + channel_b + .update(cx_b, |channel, cx| { + let task = channel + .send_message("can you see this?".to_string(), cx) + .unwrap(); + assert_eq!( + channel_messages(channel), + &[ + ("user_b".to_string(), "hello A, it's B.".to_string(), false), + ("user_b".to_string(), "can you see this?".to_string(), true) + ] + ); + task + }) + .await + .unwrap_err(); + + // Send a message from client A while B is disconnected. + channel_a + .update(cx_a, |channel, cx| { + channel + .send_message("oh, hi B.".to_string(), cx) + .unwrap() + .detach(); + let task = channel.send_message("sup".to_string(), cx).unwrap(); + assert_eq!( + channel_messages(channel), + &[ + ("user_b".to_string(), "hello A, it's B.".to_string(), false), + ("user_a".to_string(), "oh, hi B.".to_string(), true), + ("user_a".to_string(), "sup".to_string(), true) + ] + ); + task + }) + .await + .unwrap(); + + // Give client B a chance to reconnect. + server.allow_connections(); + cx_b.foreground().advance_clock(Duration::from_secs(10)); + + // Verify that B sees the new messages upon reconnection, as well as the message client B + // sent while offline. + channel_b + .condition(&cx_b, |channel, _| { + channel_messages(channel) + == [ + ("user_b".to_string(), "hello A, it's B.".to_string(), false), + ("user_a".to_string(), "oh, hi B.".to_string(), false), + ("user_a".to_string(), "sup".to_string(), false), + ("user_b".to_string(), "can you see this?".to_string(), false), + ] + }) + .await; + + // Ensure client A and B can communicate normally after reconnection. + channel_a + .update(cx_a, |channel, cx| { + channel.send_message("you online?".to_string(), cx).unwrap() + }) + .await + .unwrap(); + channel_b + .condition(&cx_b, |channel, _| { + channel_messages(channel) + == [ + ("user_b".to_string(), "hello A, it's B.".to_string(), false), + ("user_a".to_string(), "oh, hi B.".to_string(), false), + ("user_a".to_string(), "sup".to_string(), false), + ("user_b".to_string(), "can you see this?".to_string(), false), + ("user_a".to_string(), "you online?".to_string(), false), + ] + }) + .await; + + channel_b + .update(cx_b, |channel, cx| { + channel.send_message("yep".to_string(), cx).unwrap() + }) + .await + .unwrap(); + channel_a + .condition(&cx_a, |channel, _| { + channel_messages(channel) + == [ + ("user_b".to_string(), "hello A, it's B.".to_string(), false), + ("user_a".to_string(), "oh, hi B.".to_string(), false), + ("user_a".to_string(), "sup".to_string(), false), + ("user_b".to_string(), "can you see this?".to_string(), false), + ("user_a".to_string(), "you online?".to_string(), false), + ("user_b".to_string(), "yep".to_string(), false), + ] + }) + .await; +} + +#[gpui::test(iterations = 10)] +async fn test_contacts( + deterministic: Arc, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, + cx_c: &mut TestAppContext, +) { + cx_a.foreground().forbid_parking(); + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let mut client_a = server.create_client(cx_a, "user_a").await; + let mut client_b = server.create_client(cx_b, "user_b").await; + let client_c = server.create_client(cx_c, "user_c").await; + server + .make_contacts(vec![ + (&client_a, cx_a), + (&client_b, cx_b), + (&client_c, cx_c), + ]) + .await; + + deterministic.run_until_parked(); + for (client, cx) in [(&client_a, &cx_a), (&client_b, &cx_b), (&client_c, &cx_c)] { + client.user_store.read_with(*cx, |store, _| { + assert_eq!( + contacts(store), + [ + ("user_a", true, vec![]), + ("user_b", true, vec![]), + ("user_c", true, vec![]) + ], + "{} has the wrong contacts", + client.username + ) + }); + } + + // Share a project as client A. + let fs = FakeFs::new(cx_a.background()); + fs.create_dir(Path::new("/a")).await.unwrap(); + let (project_a, _) = client_a.build_local_project(fs, "/a", cx_a).await; + + deterministic.run_until_parked(); + for (client, cx) in [(&client_a, &cx_a), (&client_b, &cx_b), (&client_c, &cx_c)] { + client.user_store.read_with(*cx, |store, _| { + assert_eq!( + contacts(store), + [ + ("user_a", true, vec![("a", vec![])]), + ("user_b", true, vec![]), + ("user_c", true, vec![]) + ], + "{} has the wrong contacts", + client.username + ) + }); + } + + let _project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + + deterministic.run_until_parked(); + for (client, cx) in [(&client_a, &cx_a), (&client_b, &cx_b), (&client_c, &cx_c)] { + client.user_store.read_with(*cx, |store, _| { + assert_eq!( + contacts(store), + [ + ("user_a", true, vec![("a", vec!["user_b"])]), + ("user_b", true, vec![]), + ("user_c", true, vec![]) + ], + "{} has the wrong contacts", + client.username + ) + }); + } + + // Add a local project as client B + let fs = FakeFs::new(cx_b.background()); + fs.create_dir(Path::new("/b")).await.unwrap(); + let (_project_b, _) = client_b.build_local_project(fs, "/b", cx_a).await; + + deterministic.run_until_parked(); + for (client, cx) in [(&client_a, &cx_a), (&client_b, &cx_b), (&client_c, &cx_c)] { + client.user_store.read_with(*cx, |store, _| { + assert_eq!( + contacts(store), + [ + ("user_a", true, vec![("a", vec!["user_b"])]), + ("user_b", true, vec![("b", vec![])]), + ("user_c", true, vec![]) + ], + "{} has the wrong contacts", + client.username + ) + }); + } + + project_a + .condition(&cx_a, |project, _| { + project.collaborators().contains_key(&client_b.peer_id) + }) + .await; + + client_a.project.take(); + cx_a.update(move |_| drop(project_a)); + deterministic.run_until_parked(); + for (client, cx) in [(&client_a, &cx_a), (&client_b, &cx_b), (&client_c, &cx_c)] { + client.user_store.read_with(*cx, |store, _| { + assert_eq!( + contacts(store), + [ + ("user_a", true, vec![]), + ("user_b", true, vec![("b", vec![])]), + ("user_c", true, vec![]) + ], + "{} has the wrong contacts", + client.username + ) + }); + } + + server.disconnect_client(client_c.current_user_id(cx_c)); + server.forbid_connections(); + deterministic.advance_clock(rpc::RECEIVE_TIMEOUT); + for (client, cx) in [(&client_a, &cx_a), (&client_b, &cx_b)] { + client.user_store.read_with(*cx, |store, _| { + assert_eq!( + contacts(store), + [ + ("user_a", true, vec![]), + ("user_b", true, vec![("b", vec![])]), + ("user_c", false, vec![]) + ], + "{} has the wrong contacts", + client.username + ) + }); + } + client_c + .user_store + .read_with(cx_c, |store, _| assert_eq!(contacts(store), [])); + + server.allow_connections(); + client_c + .authenticate_and_connect(false, &cx_c.to_async()) + .await + .unwrap(); + + deterministic.run_until_parked(); + for (client, cx) in [(&client_a, &cx_a), (&client_b, &cx_b), (&client_c, &cx_c)] { + client.user_store.read_with(*cx, |store, _| { + assert_eq!( + contacts(store), + [ + ("user_a", true, vec![]), + ("user_b", true, vec![("b", vec![])]), + ("user_c", true, vec![]) + ], + "{} has the wrong contacts", + client.username + ) + }); + } + + fn contacts(user_store: &UserStore) -> Vec<(&str, bool, Vec<(&str, Vec<&str>)>)> { + user_store + .contacts() + .iter() + .map(|contact| { + let projects = contact + .projects + .iter() + .map(|p| { + ( + p.worktree_root_names[0].as_str(), + p.guests.iter().map(|p| p.github_login.as_str()).collect(), + ) + }) + .collect(); + (contact.user.github_login.as_str(), contact.online, projects) + }) + .collect() + } +} + +#[gpui::test(iterations = 10)] +async fn test_contact_requests( + executor: Arc, + cx_a: &mut TestAppContext, + cx_a2: &mut TestAppContext, + cx_b: &mut TestAppContext, + cx_b2: &mut TestAppContext, + cx_c: &mut TestAppContext, + cx_c2: &mut TestAppContext, +) { + cx_a.foreground().forbid_parking(); + + // Connect to a server as 3 clients. + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_a2 = server.create_client(cx_a2, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + let client_b2 = server.create_client(cx_b2, "user_b").await; + let client_c = server.create_client(cx_c, "user_c").await; + let client_c2 = server.create_client(cx_c2, "user_c").await; + + assert_eq!(client_a.user_id().unwrap(), client_a2.user_id().unwrap()); + assert_eq!(client_b.user_id().unwrap(), client_b2.user_id().unwrap()); + assert_eq!(client_c.user_id().unwrap(), client_c2.user_id().unwrap()); + + // User A and User C request that user B become their contact. + client_a + .user_store + .update(cx_a, |store, cx| { + store.request_contact(client_b.user_id().unwrap(), cx) + }) + .await + .unwrap(); + client_c + .user_store + .update(cx_c, |store, cx| { + store.request_contact(client_b.user_id().unwrap(), cx) + }) + .await + .unwrap(); + executor.run_until_parked(); + + // All users see the pending request appear in all their clients. + assert_eq!( + client_a.summarize_contacts(&cx_a).outgoing_requests, + &["user_b"] + ); + assert_eq!( + client_a2.summarize_contacts(&cx_a2).outgoing_requests, + &["user_b"] + ); + assert_eq!( + client_b.summarize_contacts(&cx_b).incoming_requests, + &["user_a", "user_c"] + ); + assert_eq!( + client_b2.summarize_contacts(&cx_b2).incoming_requests, + &["user_a", "user_c"] + ); + assert_eq!( + client_c.summarize_contacts(&cx_c).outgoing_requests, + &["user_b"] + ); + assert_eq!( + client_c2.summarize_contacts(&cx_c2).outgoing_requests, + &["user_b"] + ); + + // Contact requests are present upon connecting (tested here via disconnect/reconnect) + disconnect_and_reconnect(&client_a, cx_a).await; + disconnect_and_reconnect(&client_b, cx_b).await; + disconnect_and_reconnect(&client_c, cx_c).await; + executor.run_until_parked(); + assert_eq!( + client_a.summarize_contacts(&cx_a).outgoing_requests, + &["user_b"] + ); + assert_eq!( + client_b.summarize_contacts(&cx_b).incoming_requests, + &["user_a", "user_c"] + ); + assert_eq!( + client_c.summarize_contacts(&cx_c).outgoing_requests, + &["user_b"] + ); + + // User B accepts the request from user A. + client_b + .user_store + .update(cx_b, |store, cx| { + store.respond_to_contact_request(client_a.user_id().unwrap(), true, cx) + }) + .await + .unwrap(); + + executor.run_until_parked(); + + // User B sees user A as their contact now in all client, and the incoming request from them is removed. + let contacts_b = client_b.summarize_contacts(&cx_b); + assert_eq!(contacts_b.current, &["user_a", "user_b"]); + assert_eq!(contacts_b.incoming_requests, &["user_c"]); + let contacts_b2 = client_b2.summarize_contacts(&cx_b2); + assert_eq!(contacts_b2.current, &["user_a", "user_b"]); + assert_eq!(contacts_b2.incoming_requests, &["user_c"]); + + // User A sees user B as their contact now in all clients, and the outgoing request to them is removed. + let contacts_a = client_a.summarize_contacts(&cx_a); + assert_eq!(contacts_a.current, &["user_a", "user_b"]); + assert!(contacts_a.outgoing_requests.is_empty()); + let contacts_a2 = client_a2.summarize_contacts(&cx_a2); + assert_eq!(contacts_a2.current, &["user_a", "user_b"]); + assert!(contacts_a2.outgoing_requests.is_empty()); + + // Contacts are present upon connecting (tested here via disconnect/reconnect) + disconnect_and_reconnect(&client_a, cx_a).await; + disconnect_and_reconnect(&client_b, cx_b).await; + disconnect_and_reconnect(&client_c, cx_c).await; + executor.run_until_parked(); + assert_eq!( + client_a.summarize_contacts(&cx_a).current, + &["user_a", "user_b"] + ); + assert_eq!( + client_b.summarize_contacts(&cx_b).current, + &["user_a", "user_b"] + ); + assert_eq!( + client_b.summarize_contacts(&cx_b).incoming_requests, + &["user_c"] + ); + assert_eq!(client_c.summarize_contacts(&cx_c).current, &["user_c"]); + assert_eq!( + client_c.summarize_contacts(&cx_c).outgoing_requests, + &["user_b"] + ); + + // User B rejects the request from user C. + client_b + .user_store + .update(cx_b, |store, cx| { + store.respond_to_contact_request(client_c.user_id().unwrap(), false, cx) + }) + .await + .unwrap(); + + executor.run_until_parked(); + + // User B doesn't see user C as their contact, and the incoming request from them is removed. + let contacts_b = client_b.summarize_contacts(&cx_b); + assert_eq!(contacts_b.current, &["user_a", "user_b"]); + assert!(contacts_b.incoming_requests.is_empty()); + let contacts_b2 = client_b2.summarize_contacts(&cx_b2); + assert_eq!(contacts_b2.current, &["user_a", "user_b"]); + assert!(contacts_b2.incoming_requests.is_empty()); + + // User C doesn't see user B as their contact, and the outgoing request to them is removed. + let contacts_c = client_c.summarize_contacts(&cx_c); + assert_eq!(contacts_c.current, &["user_c"]); + assert!(contacts_c.outgoing_requests.is_empty()); + let contacts_c2 = client_c2.summarize_contacts(&cx_c2); + assert_eq!(contacts_c2.current, &["user_c"]); + assert!(contacts_c2.outgoing_requests.is_empty()); + + // Incoming/outgoing requests are not present upon connecting (tested here via disconnect/reconnect) + disconnect_and_reconnect(&client_a, cx_a).await; + disconnect_and_reconnect(&client_b, cx_b).await; + disconnect_and_reconnect(&client_c, cx_c).await; + executor.run_until_parked(); + assert_eq!( + client_a.summarize_contacts(&cx_a).current, + &["user_a", "user_b"] + ); + assert_eq!( + client_b.summarize_contacts(&cx_b).current, + &["user_a", "user_b"] + ); + assert!(client_b + .summarize_contacts(&cx_b) + .incoming_requests + .is_empty()); + assert_eq!(client_c.summarize_contacts(&cx_c).current, &["user_c"]); + assert!(client_c + .summarize_contacts(&cx_c) + .outgoing_requests + .is_empty()); + + async fn disconnect_and_reconnect(client: &TestClient, cx: &mut TestAppContext) { + client.disconnect(&cx.to_async()).unwrap(); + client.clear_contacts(cx).await; + client + .authenticate_and_connect(false, &cx.to_async()) + .await + .unwrap(); + } +} + +#[gpui::test(iterations = 10)] +async fn test_following(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { + cx_a.foreground().forbid_parking(); + let fs = FakeFs::new(cx_a.background()); + + // 2 clients connect to a server. + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let mut client_a = server.create_client(cx_a, "user_a").await; + let mut client_b = server.create_client(cx_b, "user_b").await; + server + .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .await; + cx_a.update(editor::init); + cx_b.update(editor::init); + + // Client A shares a project. + fs.insert_tree( + "/a", + json!({ + "1.txt": "one", + "2.txt": "two", + "3.txt": "three", + }), + ) + .await; + let (project_a, worktree_id) = client_a.build_local_project(fs.clone(), "/a", cx_a).await; + + // Client B joins the project. + let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + + // Client A opens some editors. + let workspace_a = client_a.build_workspace(&project_a, cx_a); + let pane_a = workspace_a.read_with(cx_a, |workspace, _| workspace.active_pane().clone()); + let editor_a1 = workspace_a + .update(cx_a, |workspace, cx| { + workspace.open_path((worktree_id, "1.txt"), true, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + let editor_a2 = workspace_a + .update(cx_a, |workspace, cx| { + workspace.open_path((worktree_id, "2.txt"), true, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + // Client B opens an editor. + let workspace_b = client_b.build_workspace(&project_b, cx_b); + let editor_b1 = workspace_b + .update(cx_b, |workspace, cx| { + workspace.open_path((worktree_id, "1.txt"), true, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + let client_a_id = project_b.read_with(cx_b, |project, _| { + project.collaborators().values().next().unwrap().peer_id + }); + let client_b_id = project_a.read_with(cx_a, |project, _| { + project.collaborators().values().next().unwrap().peer_id + }); + + // When client B starts following client A, all visible view states are replicated to client B. + editor_a1.update(cx_a, |editor, cx| { + editor.change_selections(None, cx, |s| s.select_ranges([0..1])) + }); + editor_a2.update(cx_a, |editor, cx| { + editor.change_selections(None, cx, |s| s.select_ranges([2..3])) + }); + workspace_b + .update(cx_b, |workspace, cx| { + workspace + .toggle_follow(&ToggleFollow(client_a_id), cx) + .unwrap() + }) + .await + .unwrap(); + + let editor_b2 = workspace_b.read_with(cx_b, |workspace, cx| { + workspace + .active_item(cx) + .unwrap() + .downcast::() + .unwrap() + }); + assert!(cx_b.read(|cx| editor_b2.is_focused(cx))); + assert_eq!( + editor_b2.read_with(cx_b, |editor, cx| editor.project_path(cx)), + Some((worktree_id, "2.txt").into()) + ); + assert_eq!( + editor_b2.read_with(cx_b, |editor, cx| editor.selections.ranges(cx)), + vec![2..3] + ); + assert_eq!( + editor_b1.read_with(cx_b, |editor, cx| editor.selections.ranges(cx)), + vec![0..1] + ); + + // When client A activates a different editor, client B does so as well. + workspace_a.update(cx_a, |workspace, cx| { + workspace.activate_item(&editor_a1, cx) + }); + workspace_b + .condition(cx_b, |workspace, cx| { + workspace.active_item(cx).unwrap().id() == editor_b1.id() + }) + .await; + + // When client A navigates back and forth, client B does so as well. + workspace_a + .update(cx_a, |workspace, cx| { + workspace::Pane::go_back(workspace, None, cx) + }) + .await; + workspace_b + .condition(cx_b, |workspace, cx| { + workspace.active_item(cx).unwrap().id() == editor_b2.id() + }) + .await; + + workspace_a + .update(cx_a, |workspace, cx| { + workspace::Pane::go_forward(workspace, None, cx) + }) + .await; + workspace_b + .condition(cx_b, |workspace, cx| { + workspace.active_item(cx).unwrap().id() == editor_b1.id() + }) + .await; + + // Changes to client A's editor are reflected on client B. + editor_a1.update(cx_a, |editor, cx| { + editor.change_selections(None, cx, |s| s.select_ranges([1..1, 2..2])); + }); + editor_b1 + .condition(cx_b, |editor, cx| { + editor.selections.ranges(cx) == vec![1..1, 2..2] + }) + .await; + + editor_a1.update(cx_a, |editor, cx| editor.set_text("TWO", cx)); + editor_b1 + .condition(cx_b, |editor, cx| editor.text(cx) == "TWO") + .await; + + editor_a1.update(cx_a, |editor, cx| { + editor.change_selections(None, cx, |s| s.select_ranges([3..3])); + editor.set_scroll_position(vec2f(0., 100.), cx); + }); + editor_b1 + .condition(cx_b, |editor, cx| { + editor.selections.ranges(cx) == vec![3..3] + }) + .await; + + // After unfollowing, client B stops receiving updates from client A. + workspace_b.update(cx_b, |workspace, cx| { + workspace.unfollow(&workspace.active_pane().clone(), cx) + }); + workspace_a.update(cx_a, |workspace, cx| { + workspace.activate_item(&editor_a2, cx) + }); + cx_a.foreground().run_until_parked(); + assert_eq!( + workspace_b.read_with(cx_b, |workspace, cx| workspace + .active_item(cx) + .unwrap() + .id()), + editor_b1.id() + ); + + // Client A starts following client B. + workspace_a + .update(cx_a, |workspace, cx| { + workspace + .toggle_follow(&ToggleFollow(client_b_id), cx) + .unwrap() + }) + .await + .unwrap(); + assert_eq!( + workspace_a.read_with(cx_a, |workspace, _| workspace.leader_for_pane(&pane_a)), + Some(client_b_id) + ); + assert_eq!( + workspace_a.read_with(cx_a, |workspace, cx| workspace + .active_item(cx) + .unwrap() + .id()), + editor_a1.id() + ); + + // Following interrupts when client B disconnects. + client_b.disconnect(&cx_b.to_async()).unwrap(); + cx_a.foreground().run_until_parked(); + assert_eq!( + workspace_a.read_with(cx_a, |workspace, _| workspace.leader_for_pane(&pane_a)), + None + ); +} + +#[gpui::test(iterations = 10)] +async fn test_peers_following_each_other(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { + cx_a.foreground().forbid_parking(); + let fs = FakeFs::new(cx_a.background()); + + // 2 clients connect to a server. + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let mut client_a = server.create_client(cx_a, "user_a").await; + let mut client_b = server.create_client(cx_b, "user_b").await; + server + .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .await; + cx_a.update(editor::init); + cx_b.update(editor::init); + + // Client A shares a project. + fs.insert_tree( + "/a", + json!({ + "1.txt": "one", + "2.txt": "two", + "3.txt": "three", + "4.txt": "four", + }), + ) + .await; + let (project_a, worktree_id) = client_a.build_local_project(fs.clone(), "/a", cx_a).await; + + // Client B joins the project. + let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + + // Client A opens some editors. + let workspace_a = client_a.build_workspace(&project_a, cx_a); + let pane_a1 = workspace_a.read_with(cx_a, |workspace, _| workspace.active_pane().clone()); + let _editor_a1 = workspace_a + .update(cx_a, |workspace, cx| { + workspace.open_path((worktree_id, "1.txt"), true, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + // Client B opens an editor. + let workspace_b = client_b.build_workspace(&project_b, cx_b); + let pane_b1 = workspace_b.read_with(cx_b, |workspace, _| workspace.active_pane().clone()); + let _editor_b1 = workspace_b + .update(cx_b, |workspace, cx| { + workspace.open_path((worktree_id, "2.txt"), true, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + // Clients A and B follow each other in split panes + workspace_a.update(cx_a, |workspace, cx| { + workspace.split_pane(workspace.active_pane().clone(), SplitDirection::Right, cx); + assert_ne!(*workspace.active_pane(), pane_a1); + }); + workspace_a + .update(cx_a, |workspace, cx| { + let leader_id = *project_a.read(cx).collaborators().keys().next().unwrap(); + workspace + .toggle_follow(&workspace::ToggleFollow(leader_id), cx) + .unwrap() + }) + .await + .unwrap(); + workspace_b.update(cx_b, |workspace, cx| { + workspace.split_pane(workspace.active_pane().clone(), SplitDirection::Right, cx); + assert_ne!(*workspace.active_pane(), pane_b1); + }); + workspace_b + .update(cx_b, |workspace, cx| { + let leader_id = *project_b.read(cx).collaborators().keys().next().unwrap(); + workspace + .toggle_follow(&workspace::ToggleFollow(leader_id), cx) + .unwrap() + }) + .await + .unwrap(); + + workspace_a + .update(cx_a, |workspace, cx| { + workspace.activate_next_pane(cx); + assert_eq!(*workspace.active_pane(), pane_a1); + workspace.open_path((worktree_id, "3.txt"), true, cx) + }) + .await + .unwrap(); + workspace_b + .update(cx_b, |workspace, cx| { + workspace.activate_next_pane(cx); + assert_eq!(*workspace.active_pane(), pane_b1); + workspace.open_path((worktree_id, "4.txt"), true, cx) + }) + .await + .unwrap(); + cx_a.foreground().run_until_parked(); + + // Ensure leader updates don't change the active pane of followers + workspace_a.read_with(cx_a, |workspace, _| { + assert_eq!(*workspace.active_pane(), pane_a1); + }); + workspace_b.read_with(cx_b, |workspace, _| { + assert_eq!(*workspace.active_pane(), pane_b1); + }); + + // Ensure peers following each other doesn't cause an infinite loop. + assert_eq!( + workspace_a.read_with(cx_a, |workspace, cx| workspace + .active_item(cx) + .unwrap() + .project_path(cx)), + Some((worktree_id, "3.txt").into()) + ); + workspace_a.update(cx_a, |workspace, cx| { + assert_eq!( + workspace.active_item(cx).unwrap().project_path(cx), + Some((worktree_id, "3.txt").into()) + ); + workspace.activate_next_pane(cx); + assert_eq!( + workspace.active_item(cx).unwrap().project_path(cx), + Some((worktree_id, "4.txt").into()) + ); + }); + workspace_b.update(cx_b, |workspace, cx| { + assert_eq!( + workspace.active_item(cx).unwrap().project_path(cx), + Some((worktree_id, "4.txt").into()) + ); + workspace.activate_next_pane(cx); + assert_eq!( + workspace.active_item(cx).unwrap().project_path(cx), + Some((worktree_id, "3.txt").into()) + ); + }); +} + +#[gpui::test(iterations = 10)] +async fn test_auto_unfollowing(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { + cx_a.foreground().forbid_parking(); + + // 2 clients connect to a server. + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; + let mut client_a = server.create_client(cx_a, "user_a").await; + let mut client_b = server.create_client(cx_b, "user_b").await; + server + .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) + .await; + cx_a.update(editor::init); + cx_b.update(editor::init); + + // Client A shares a project. + let fs = FakeFs::new(cx_a.background()); + fs.insert_tree( + "/a", + json!({ + "1.txt": "one", + "2.txt": "two", + "3.txt": "three", + }), + ) + .await; + let (project_a, worktree_id) = client_a.build_local_project(fs.clone(), "/a", cx_a).await; + let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; + + // Client A opens some editors. + let workspace_a = client_a.build_workspace(&project_a, cx_a); + let _editor_a1 = workspace_a + .update(cx_a, |workspace, cx| { + workspace.open_path((worktree_id, "1.txt"), true, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + // Client B starts following client A. + let workspace_b = client_b.build_workspace(&project_b, cx_b); + let pane_b = workspace_b.read_with(cx_b, |workspace, _| workspace.active_pane().clone()); + let leader_id = project_b.read_with(cx_b, |project, _| { + project.collaborators().values().next().unwrap().peer_id + }); + workspace_b + .update(cx_b, |workspace, cx| { + workspace + .toggle_follow(&ToggleFollow(leader_id), cx) + .unwrap() + }) + .await + .unwrap(); + assert_eq!( + workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), + Some(leader_id) + ); + let editor_b2 = workspace_b.read_with(cx_b, |workspace, cx| { + workspace + .active_item(cx) + .unwrap() + .downcast::() + .unwrap() + }); + + // When client B moves, it automatically stops following client A. + editor_b2.update(cx_b, |editor, cx| editor.move_right(&editor::MoveRight, cx)); + assert_eq!( + workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), + None + ); + + workspace_b + .update(cx_b, |workspace, cx| { + workspace + .toggle_follow(&ToggleFollow(leader_id), cx) + .unwrap() + }) + .await + .unwrap(); + assert_eq!( + workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), + Some(leader_id) + ); + + // When client B edits, it automatically stops following client A. + editor_b2.update(cx_b, |editor, cx| editor.insert("X", cx)); + assert_eq!( + workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), + None + ); + + workspace_b + .update(cx_b, |workspace, cx| { + workspace + .toggle_follow(&ToggleFollow(leader_id), cx) + .unwrap() + }) + .await + .unwrap(); + assert_eq!( + workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), + Some(leader_id) + ); + + // When client B scrolls, it automatically stops following client A. + editor_b2.update(cx_b, |editor, cx| { + editor.set_scroll_position(vec2f(0., 3.), cx) + }); + assert_eq!( + workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), + None + ); + + workspace_b + .update(cx_b, |workspace, cx| { + workspace + .toggle_follow(&ToggleFollow(leader_id), cx) + .unwrap() + }) + .await + .unwrap(); + assert_eq!( + workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), + Some(leader_id) + ); + + // When client B activates a different pane, it continues following client A in the original pane. + workspace_b.update(cx_b, |workspace, cx| { + workspace.split_pane(pane_b.clone(), SplitDirection::Right, cx) + }); + assert_eq!( + workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), + Some(leader_id) + ); + + workspace_b.update(cx_b, |workspace, cx| workspace.activate_next_pane(cx)); + assert_eq!( + workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), + Some(leader_id) + ); + + // When client B activates a different item in the original pane, it automatically stops following client A. + workspace_b + .update(cx_b, |workspace, cx| { + workspace.open_path((worktree_id, "2.txt"), true, cx) + }) + .await + .unwrap(); + assert_eq!( + workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), + None + ); +} + +#[gpui::test(iterations = 100)] +async fn test_random_collaboration( + cx: &mut TestAppContext, + deterministic: Arc, + rng: StdRng, +) { + cx.foreground().forbid_parking(); + let max_peers = env::var("MAX_PEERS") + .map(|i| i.parse().expect("invalid `MAX_PEERS` variable")) + .unwrap_or(5); + assert!(max_peers <= 5); + + let max_operations = env::var("OPERATIONS") + .map(|i| i.parse().expect("invalid `OPERATIONS` variable")) + .unwrap_or(10); + + let rng = Arc::new(Mutex::new(rng)); + + let guest_lang_registry = Arc::new(LanguageRegistry::test()); + let host_language_registry = Arc::new(LanguageRegistry::test()); + + let fs = FakeFs::new(cx.background()); + fs.insert_tree("/_collab", json!({"init": ""})).await; + + let mut server = TestServer::start(cx.foreground(), cx.background()).await; + let db = server.app_state.db.clone(); + let host_user_id = db.create_user("host", None, false).await.unwrap(); + for username in ["guest-1", "guest-2", "guest-3", "guest-4"] { + let guest_user_id = db.create_user(username, None, false).await.unwrap(); + server + .app_state + .db + .send_contact_request(guest_user_id, host_user_id) + .await + .unwrap(); + server + .app_state + .db + .respond_to_contact_request(host_user_id, guest_user_id, true) + .await + .unwrap(); + } + + let mut clients = Vec::new(); + let mut user_ids = Vec::new(); + let mut op_start_signals = Vec::new(); + + let mut next_entity_id = 100000; + let mut host_cx = TestAppContext::new( + cx.foreground_platform(), + cx.platform(), + deterministic.build_foreground(next_entity_id), + deterministic.build_background(), + cx.font_cache(), + cx.leak_detector(), + next_entity_id, + ); + let host = server.create_client(&mut host_cx, "host").await; + let host_project = host_cx.update(|cx| { + Project::local( + host.client.clone(), + host.user_store.clone(), + host_language_registry.clone(), + fs.clone(), + cx, + ) + }); + let host_project_id = host_project + .update(&mut host_cx, |p, _| p.next_remote_id()) + .await; + + let (collab_worktree, _) = host_project + .update(&mut host_cx, |project, cx| { + project.find_or_create_local_worktree("/_collab", true, cx) + }) + .await + .unwrap(); + collab_worktree + .read_with(&host_cx, |tree, _| tree.as_local().unwrap().scan_complete()) + .await; + + // Set up fake language servers. + let mut language = Language::new( + LanguageConfig { + name: "Rust".into(), + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + None, + ); + let _fake_servers = language.set_fake_lsp_adapter(FakeLspAdapter { + name: "the-fake-language-server", + capabilities: lsp::LanguageServer::full_capabilities(), + initializer: Some(Box::new({ + let rng = rng.clone(); + let fs = fs.clone(); + let project = host_project.downgrade(); + move |fake_server: &mut FakeLanguageServer| { + fake_server.handle_request::(|_, _| async move { + Ok(Some(lsp::CompletionResponse::Array(vec![ + lsp::CompletionItem { + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range::new( + lsp::Position::new(0, 0), + lsp::Position::new(0, 0), + ), + new_text: "the-new-text".to_string(), + })), + ..Default::default() + }, + ]))) + }); + + fake_server.handle_request::( + |_, _| async move { + Ok(Some(vec![lsp::CodeActionOrCommand::CodeAction( + lsp::CodeAction { + title: "the-code-action".to_string(), + ..Default::default() + }, + )])) + }, + ); + + fake_server.handle_request::( + |params, _| async move { + Ok(Some(lsp::PrepareRenameResponse::Range(lsp::Range::new( + params.position, + params.position, + )))) + }, + ); + + fake_server.handle_request::({ + let fs = fs.clone(); + let rng = rng.clone(); + move |_, _| { + let fs = fs.clone(); + let rng = rng.clone(); + async move { + let files = fs.files().await; + let mut rng = rng.lock(); + let count = rng.gen_range::(1..3); + let files = (0..count) + .map(|_| files.choose(&mut *rng).unwrap()) + .collect::>(); + log::info!("LSP: Returning definitions in files {:?}", &files); + Ok(Some(lsp::GotoDefinitionResponse::Array( + files + .into_iter() + .map(|file| lsp::Location { + uri: lsp::Url::from_file_path(file).unwrap(), + range: Default::default(), + }) + .collect(), + ))) + } + } + }); + + fake_server.handle_request::({ + let rng = rng.clone(); + let project = project.clone(); + move |params, mut cx| { + let highlights = if let Some(project) = project.upgrade(&cx) { + project.update(&mut cx, |project, cx| { + let path = params + .text_document_position_params + .text_document + .uri + .to_file_path() + .unwrap(); + let (worktree, relative_path) = + project.find_local_worktree(&path, cx)?; + let project_path = + ProjectPath::from((worktree.read(cx).id(), relative_path)); + let buffer = project.get_open_buffer(&project_path, cx)?.read(cx); + + let mut highlights = Vec::new(); + let highlight_count = rng.lock().gen_range(1..=5); + let mut prev_end = 0; + for _ in 0..highlight_count { + let range = + buffer.random_byte_range(prev_end, &mut *rng.lock()); + + highlights.push(lsp::DocumentHighlight { + range: range_to_lsp(range.to_point_utf16(buffer)), + kind: Some(lsp::DocumentHighlightKind::READ), + }); + prev_end = range.end; + } + Some(highlights) + }) + } else { + None + }; + async move { Ok(highlights) } + } + }); + } + })), + ..Default::default() + }); + host_language_registry.add(Arc::new(language)); + + let op_start_signal = futures::channel::mpsc::unbounded(); + user_ids.push(host.current_user_id(&host_cx)); + op_start_signals.push(op_start_signal.0); + clients.push(host_cx.foreground().spawn(host.simulate_host( + host_project, + op_start_signal.1, + rng.clone(), + host_cx, + ))); + + let disconnect_host_at = if rng.lock().gen_bool(0.2) { + rng.lock().gen_range(0..max_operations) + } else { + max_operations + }; + let mut available_guests = vec![ + "guest-1".to_string(), + "guest-2".to_string(), + "guest-3".to_string(), + "guest-4".to_string(), + ]; + let mut operations = 0; + while operations < max_operations { + if operations == disconnect_host_at { + server.disconnect_client(user_ids[0]); + cx.foreground().advance_clock(RECEIVE_TIMEOUT); + drop(op_start_signals); + let mut clients = futures::future::join_all(clients).await; + cx.foreground().run_until_parked(); + + let (host, mut host_cx, host_err) = clients.remove(0); + if let Some(host_err) = host_err { + log::error!("host error - {:?}", host_err); + } + host.project + .as_ref() + .unwrap() + .read_with(&host_cx, |project, _| assert!(!project.is_shared())); + for (guest, mut guest_cx, guest_err) in clients { + if let Some(guest_err) = guest_err { + log::error!("{} error - {:?}", guest.username, guest_err); + } + + let contacts = server + .app_state + .db + .get_contacts(guest.current_user_id(&guest_cx)) + .await + .unwrap(); + let contacts = server + .store + .read() + .await + .build_initial_contacts_update(contacts) + .contacts; + assert!(!contacts + .iter() + .flat_map(|contact| &contact.projects) + .any(|project| project.id == host_project_id)); + guest + .project + .as_ref() + .unwrap() + .read_with(&guest_cx, |project, _| assert!(project.is_read_only())); + guest_cx.update(|_| drop(guest)); + } + host_cx.update(|_| drop(host)); + + return; + } + + let distribution = rng.lock().gen_range(0..100); + match distribution { + 0..=19 if !available_guests.is_empty() => { + let guest_ix = rng.lock().gen_range(0..available_guests.len()); + let guest_username = available_guests.remove(guest_ix); + log::info!("Adding new connection for {}", guest_username); + next_entity_id += 100000; + let mut guest_cx = TestAppContext::new( + cx.foreground_platform(), + cx.platform(), + deterministic.build_foreground(next_entity_id), + deterministic.build_background(), + cx.font_cache(), + cx.leak_detector(), + next_entity_id, + ); + let guest = server.create_client(&mut guest_cx, &guest_username).await; + let guest_project = Project::remote( + host_project_id, + guest.client.clone(), + guest.user_store.clone(), + guest_lang_registry.clone(), + FakeFs::new(cx.background()), + &mut guest_cx.to_async(), + ) + .await + .unwrap(); + let op_start_signal = futures::channel::mpsc::unbounded(); + user_ids.push(guest.current_user_id(&guest_cx)); + op_start_signals.push(op_start_signal.0); + clients.push(guest_cx.foreground().spawn(guest.simulate_guest( + guest_username.clone(), + guest_project, + op_start_signal.1, + rng.clone(), + guest_cx, + ))); + + log::info!("Added connection for {}", guest_username); + operations += 1; + } + 20..=29 if clients.len() > 1 => { + let guest_ix = rng.lock().gen_range(1..clients.len()); + log::info!("Removing guest {}", user_ids[guest_ix]); + let removed_guest_id = user_ids.remove(guest_ix); + let guest = clients.remove(guest_ix); + op_start_signals.remove(guest_ix); + server.forbid_connections(); + server.disconnect_client(removed_guest_id); + cx.foreground().advance_clock(RECEIVE_TIMEOUT); + let (guest, mut guest_cx, guest_err) = guest.await; + server.allow_connections(); + + if let Some(guest_err) = guest_err { + log::error!("{} error - {:?}", guest.username, guest_err); + } + guest + .project + .as_ref() + .unwrap() + .read_with(&guest_cx, |project, _| assert!(project.is_read_only())); + for user_id in &user_ids { + let contacts = server.app_state.db.get_contacts(*user_id).await.unwrap(); + let contacts = server + .store + .read() + .await + .build_initial_contacts_update(contacts) + .contacts; + for contact in contacts { + if contact.online { + assert_ne!( + contact.user_id, removed_guest_id.0 as u64, + "removed guest is still a contact of another peer" + ); + } + for project in contact.projects { + for project_guest_id in project.guests { + assert_ne!( + project_guest_id, removed_guest_id.0 as u64, + "removed guest appears as still participating on a project" + ); + } + } + } + } + + log::info!("{} removed", guest.username); + available_guests.push(guest.username.clone()); + guest_cx.update(|_| drop(guest)); + + operations += 1; + } + _ => { + while operations < max_operations && rng.lock().gen_bool(0.7) { + op_start_signals + .choose(&mut *rng.lock()) + .unwrap() + .unbounded_send(()) + .unwrap(); + operations += 1; + } + + if rng.lock().gen_bool(0.8) { + cx.foreground().run_until_parked(); + } + } + } + } + + drop(op_start_signals); + let mut clients = futures::future::join_all(clients).await; + cx.foreground().run_until_parked(); + + let (host_client, mut host_cx, host_err) = clients.remove(0); + if let Some(host_err) = host_err { + panic!("host error - {:?}", host_err); + } + let host_project = host_client.project.as_ref().unwrap(); + let host_worktree_snapshots = host_project.read_with(&host_cx, |project, cx| { + project + .worktrees(cx) + .map(|worktree| { + let snapshot = worktree.read(cx).snapshot(); + (snapshot.id(), snapshot) + }) + .collect::>() + }); + + host_client + .project + .as_ref() + .unwrap() + .read_with(&host_cx, |project, cx| project.check_invariants(cx)); + + for (guest_client, mut guest_cx, guest_err) in clients.into_iter() { + if let Some(guest_err) = guest_err { + panic!("{} error - {:?}", guest_client.username, guest_err); + } + let worktree_snapshots = + guest_client + .project + .as_ref() + .unwrap() + .read_with(&guest_cx, |project, cx| { + project + .worktrees(cx) + .map(|worktree| { + let worktree = worktree.read(cx); + (worktree.id(), worktree.snapshot()) + }) + .collect::>() + }); + + assert_eq!( + worktree_snapshots.keys().collect::>(), + host_worktree_snapshots.keys().collect::>(), + "{} has different worktrees than the host", + guest_client.username + ); + for (id, host_snapshot) in &host_worktree_snapshots { + let guest_snapshot = &worktree_snapshots[id]; + assert_eq!( + guest_snapshot.root_name(), + host_snapshot.root_name(), + "{} has different root name than the host for worktree {}", + guest_client.username, + id + ); + assert_eq!( + guest_snapshot.entries(false).collect::>(), + host_snapshot.entries(false).collect::>(), + "{} has different snapshot than the host for worktree {}", + guest_client.username, + id + ); + assert_eq!(guest_snapshot.scan_id(), host_snapshot.scan_id()); + } + + guest_client + .project + .as_ref() + .unwrap() + .read_with(&guest_cx, |project, cx| project.check_invariants(cx)); + + for guest_buffer in &guest_client.buffers { + let buffer_id = guest_buffer.read_with(&guest_cx, |buffer, _| buffer.remote_id()); + let host_buffer = host_project.read_with(&host_cx, |project, cx| { + project.buffer_for_id(buffer_id, cx).expect(&format!( + "host does not have buffer for guest:{}, peer:{}, id:{}", + guest_client.username, guest_client.peer_id, buffer_id + )) + }); + let path = + host_buffer.read_with(&host_cx, |buffer, cx| buffer.file().unwrap().full_path(cx)); + + assert_eq!( + guest_buffer.read_with(&guest_cx, |buffer, _| buffer.deferred_ops_len()), + 0, + "{}, buffer {}, path {:?} has deferred operations", + guest_client.username, + buffer_id, + path, + ); + assert_eq!( + guest_buffer.read_with(&guest_cx, |buffer, _| buffer.text()), + host_buffer.read_with(&host_cx, |buffer, _| buffer.text()), + "{}, buffer {}, path {:?}, differs from the host's buffer", + guest_client.username, + buffer_id, + path + ); + } + + guest_cx.update(|_| drop(guest_client)); + } + + host_cx.update(|_| drop(host_client)); +} + +struct TestServer { + peer: Arc, + app_state: Arc, + server: Arc, + foreground: Rc, + notifications: mpsc::UnboundedReceiver<()>, + connection_killers: Arc>>>, + forbid_connections: Arc, + _test_db: TestDb, +} + +impl TestServer { + async fn start( + foreground: Rc, + background: Arc, + ) -> Self { + let test_db = TestDb::fake(background); + let app_state = Self::build_app_state(&test_db).await; + let peer = Peer::new(); + let notifications = mpsc::unbounded(); + let server = Server::new(app_state.clone(), Some(notifications.0)); + Self { + peer, + app_state, + server, + foreground, + notifications: notifications.1, + connection_killers: Default::default(), + forbid_connections: Default::default(), + _test_db: test_db, + } + } + + async fn create_client(&mut self, cx: &mut TestAppContext, name: &str) -> TestClient { + cx.update(|cx| { + let settings = Settings::test(cx); + cx.set_global(settings); + }); + + let http = FakeHttpClient::with_404_response(); + let user_id = if let Ok(Some(user)) = self.app_state.db.get_user_by_github_login(name).await + { + user.id + } else { + self.app_state + .db + .create_user(name, None, false) + .await + .unwrap() + }; + let client_name = name.to_string(); + let mut client = Client::new(http.clone()); + let server = self.server.clone(); + let db = self.app_state.db.clone(); + let connection_killers = self.connection_killers.clone(); + let forbid_connections = self.forbid_connections.clone(); + let (connection_id_tx, mut connection_id_rx) = mpsc::channel(16); + + Arc::get_mut(&mut client) + .unwrap() + .override_authenticate(move |cx| { + cx.spawn(|_| async move { + let access_token = "the-token".to_string(); + Ok(Credentials { + user_id: user_id.0 as u64, + access_token, + }) + }) + }) + .override_establish_connection(move |credentials, cx| { + assert_eq!(credentials.user_id, user_id.0 as u64); + assert_eq!(credentials.access_token, "the-token"); + + let server = server.clone(); + let db = db.clone(); + let connection_killers = connection_killers.clone(); + let forbid_connections = forbid_connections.clone(); + let client_name = client_name.clone(); + let connection_id_tx = connection_id_tx.clone(); + cx.spawn(move |cx| async move { + if forbid_connections.load(SeqCst) { + Err(EstablishConnectionError::other(anyhow!( + "server is forbidding connections" + ))) + } else { + let (client_conn, server_conn, killed) = + Connection::in_memory(cx.background()); + connection_killers.lock().insert(user_id, killed); + let user = db.get_user_by_id(user_id).await.unwrap().unwrap(); + cx.background() + .spawn(server.handle_connection( + server_conn, + client_name, + user, + Some(connection_id_tx), + cx.background(), + )) + .detach(); + Ok(client_conn) + } + }) + }); + + let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http, cx)); + let app_state = Arc::new(workspace::AppState { + client: client.clone(), + user_store: user_store.clone(), + languages: Arc::new(LanguageRegistry::new(Task::ready(()))), + themes: ThemeRegistry::new((), cx.font_cache()), + fs: FakeFs::new(cx.background()), + build_window_options: || Default::default(), + initialize_workspace: |_, _, _| unimplemented!(), + }); + + Channel::init(&client); + Project::init(&client); + cx.update(|cx| workspace::init(app_state.clone(), cx)); + + client + .authenticate_and_connect(false, &cx.to_async()) + .await + .unwrap(); + let peer_id = PeerId(connection_id_rx.next().await.unwrap().0); + + let client = TestClient { + client, + peer_id, + username: name.to_string(), + user_store, + language_registry: Arc::new(LanguageRegistry::test()), + project: Default::default(), + buffers: Default::default(), + }; + client.wait_for_current_user(cx).await; + client + } + + fn disconnect_client(&self, user_id: UserId) { + self.connection_killers + .lock() + .remove(&user_id) + .unwrap() + .store(true, SeqCst); + } + + fn forbid_connections(&self) { + self.forbid_connections.store(true, SeqCst); + } + + fn allow_connections(&self) { + self.forbid_connections.store(false, SeqCst); + } + + async fn make_contacts(&self, mut clients: Vec<(&TestClient, &mut TestAppContext)>) { + while let Some((client_a, cx_a)) = clients.pop() { + for (client_b, cx_b) in &mut clients { + client_a + .user_store + .update(cx_a, |store, cx| { + store.request_contact(client_b.user_id().unwrap(), cx) + }) + .await + .unwrap(); + cx_a.foreground().run_until_parked(); + client_b + .user_store + .update(*cx_b, |store, cx| { + store.respond_to_contact_request(client_a.user_id().unwrap(), true, cx) + }) + .await + .unwrap(); + } + } + } + + async fn build_app_state(test_db: &TestDb) -> Arc { + Arc::new(AppState { + db: test_db.db().clone(), + api_token: Default::default(), + invite_link_prefix: Default::default(), + }) + } + + async fn state<'a>(&'a self) -> RwLockReadGuard<'a, Store> { + self.server.store.read().await + } + + async fn condition(&mut self, mut predicate: F) + where + F: FnMut(&Store) -> bool, + { + assert!( + self.foreground.parking_forbidden(), + "you must call forbid_parking to use server conditions so we don't block indefinitely" + ); + while !(predicate)(&*self.server.store.read().await) { + self.foreground.start_waiting(); + self.notifications.next().await; + self.foreground.finish_waiting(); + } + } +} + +impl Deref for TestServer { + type Target = Server; + + fn deref(&self) -> &Self::Target { + &self.server + } +} + +impl Drop for TestServer { + fn drop(&mut self) { + self.peer.reset(); + } +} + +struct TestClient { + client: Arc, + username: String, + pub peer_id: PeerId, + pub user_store: ModelHandle, + language_registry: Arc, + project: Option>, + buffers: HashSet>, +} + +impl Deref for TestClient { + type Target = Arc; + + fn deref(&self) -> &Self::Target { + &self.client + } +} + +struct ContactsSummary { + pub current: Vec, + pub outgoing_requests: Vec, + pub incoming_requests: Vec, +} + +impl TestClient { + pub fn current_user_id(&self, cx: &TestAppContext) -> UserId { + UserId::from_proto( + self.user_store + .read_with(cx, |user_store, _| user_store.current_user().unwrap().id), + ) + } + + async fn wait_for_current_user(&self, cx: &TestAppContext) { + let mut authed_user = self + .user_store + .read_with(cx, |user_store, _| user_store.watch_current_user()); + while authed_user.next().await.unwrap().is_none() {} + } + + async fn clear_contacts(&self, cx: &mut TestAppContext) { + self.user_store + .update(cx, |store, _| store.clear_contacts()) + .await; + } + + fn summarize_contacts(&self, cx: &TestAppContext) -> ContactsSummary { + self.user_store.read_with(cx, |store, _| ContactsSummary { + current: store + .contacts() + .iter() + .map(|contact| contact.user.github_login.clone()) + .collect(), + outgoing_requests: store + .outgoing_contact_requests() + .iter() + .map(|user| user.github_login.clone()) + .collect(), + incoming_requests: store + .incoming_contact_requests() + .iter() + .map(|user| user.github_login.clone()) + .collect(), + }) + } + + async fn build_local_project( + &mut self, + fs: Arc, + root_path: impl AsRef, + cx: &mut TestAppContext, + ) -> (ModelHandle, WorktreeId) { + let project = cx.update(|cx| { + Project::local( + self.client.clone(), + self.user_store.clone(), + self.language_registry.clone(), + fs, + cx, + ) + }); + self.project = Some(project.clone()); + let (worktree, _) = project + .update(cx, |p, cx| { + p.find_or_create_local_worktree(root_path, true, cx) + }) + .await + .unwrap(); + worktree + .read_with(cx, |tree, _| tree.as_local().unwrap().scan_complete()) + .await; + project + .update(cx, |project, _| project.next_remote_id()) + .await; + (project, worktree.read_with(cx, |tree, _| tree.id())) + } + + async fn build_remote_project( + &mut self, + host_project: &ModelHandle, + host_cx: &mut TestAppContext, + guest_cx: &mut TestAppContext, + ) -> ModelHandle { + let host_project_id = host_project + .read_with(host_cx, |project, _| project.next_remote_id()) + .await; + let guest_user_id = self.user_id().unwrap(); + let languages = host_project.read_with(host_cx, |project, _| project.languages().clone()); + let project_b = guest_cx.spawn(|mut cx| { + let user_store = self.user_store.clone(); + let guest_client = self.client.clone(); + async move { + Project::remote( + host_project_id, + guest_client, + user_store.clone(), + languages, + FakeFs::new(cx.background()), + &mut cx, + ) + .await + .unwrap() + } + }); + host_cx.foreground().run_until_parked(); + host_project.update(host_cx, |project, cx| { + project.respond_to_join_request(guest_user_id, true, cx) + }); + let project = project_b.await; + self.project = Some(project.clone()); + project + } + + fn build_workspace( + &self, + project: &ModelHandle, + cx: &mut TestAppContext, + ) -> ViewHandle { + let (window_id, _) = cx.add_window(|_| EmptyView); + cx.add_view(window_id, |cx| Workspace::new(project.clone(), cx)) + } + + async fn simulate_host( + mut self, + project: ModelHandle, + op_start_signal: futures::channel::mpsc::UnboundedReceiver<()>, + rng: Arc>, + mut cx: TestAppContext, + ) -> (Self, TestAppContext, Option) { + async fn simulate_host_internal( + client: &mut TestClient, + project: ModelHandle, + mut op_start_signal: futures::channel::mpsc::UnboundedReceiver<()>, + rng: Arc>, + cx: &mut TestAppContext, + ) -> anyhow::Result<()> { + let fs = project.read_with(cx, |project, _| project.fs().clone()); + + cx.update(|cx| { + cx.subscribe(&project, move |project, event, cx| { + if let project::Event::ContactRequestedJoin(user) = event { + log::info!("Host: accepting join request from {}", user.github_login); + project.update(cx, |project, cx| { + project.respond_to_join_request(user.id, true, cx) + }); + } + }) + .detach(); + }); + + while op_start_signal.next().await.is_some() { + let distribution = rng.lock().gen_range::(0..100); + let files = fs.as_fake().files().await; + match distribution { + 0..=19 if !files.is_empty() => { + let path = files.choose(&mut *rng.lock()).unwrap(); + let mut path = path.as_path(); + while let Some(parent_path) = path.parent() { + path = parent_path; + if rng.lock().gen() { + break; + } + } + + log::info!("Host: find/create local worktree {:?}", path); + let find_or_create_worktree = project.update(cx, |project, cx| { + project.find_or_create_local_worktree(path, true, cx) + }); + if rng.lock().gen() { + cx.background().spawn(find_or_create_worktree).detach(); + } else { + find_or_create_worktree.await?; + } + } + 20..=79 if !files.is_empty() => { + let buffer = if client.buffers.is_empty() || rng.lock().gen() { + let file = files.choose(&mut *rng.lock()).unwrap(); + let (worktree, path) = project + .update(cx, |project, cx| { + project.find_or_create_local_worktree(file.clone(), true, cx) + }) + .await?; + let project_path = + worktree.read_with(cx, |worktree, _| (worktree.id(), path)); + log::info!( + "Host: opening path {:?}, worktree {}, relative_path {:?}", + file, + project_path.0, + project_path.1 + ); + let buffer = project + .update(cx, |project, cx| project.open_buffer(project_path, cx)) + .await + .unwrap(); + client.buffers.insert(buffer.clone()); + buffer + } else { + client + .buffers + .iter() + .choose(&mut *rng.lock()) + .unwrap() + .clone() + }; + + if rng.lock().gen_bool(0.1) { + cx.update(|cx| { + log::info!( + "Host: dropping buffer {:?}", + buffer.read(cx).file().unwrap().full_path(cx) + ); + client.buffers.remove(&buffer); + drop(buffer); + }); + } else { + buffer.update(cx, |buffer, cx| { + log::info!( + "Host: updating buffer {:?} ({})", + buffer.file().unwrap().full_path(cx), + buffer.remote_id() + ); + + if rng.lock().gen_bool(0.7) { + buffer.randomly_edit(&mut *rng.lock(), 5, cx); + } else { + buffer.randomly_undo_redo(&mut *rng.lock(), cx); + } + }); + } + } + _ => loop { + let path_component_count = rng.lock().gen_range::(1..=5); + let mut path = PathBuf::new(); + path.push("/"); + for _ in 0..path_component_count { + let letter = rng.lock().gen_range(b'a'..=b'z'); + path.push(std::str::from_utf8(&[letter]).unwrap()); + } + path.set_extension("rs"); + let parent_path = path.parent().unwrap(); + + log::info!("Host: creating file {:?}", path,); + + if fs.create_dir(&parent_path).await.is_ok() + && fs.create_file(&path, Default::default()).await.is_ok() + { + break; + } else { + log::info!("Host: cannot create file"); + } + }, + } + + cx.background().simulate_random_delay().await; + } + + Ok(()) + } + + let result = + simulate_host_internal(&mut self, project.clone(), op_start_signal, rng, &mut cx).await; + log::info!("Host done"); + self.project = Some(project); + (self, cx, result.err()) + } + + pub async fn simulate_guest( + mut self, + guest_username: String, + project: ModelHandle, + op_start_signal: futures::channel::mpsc::UnboundedReceiver<()>, + rng: Arc>, + mut cx: TestAppContext, + ) -> (Self, TestAppContext, Option) { + async fn simulate_guest_internal( + client: &mut TestClient, + guest_username: &str, + project: ModelHandle, + mut op_start_signal: futures::channel::mpsc::UnboundedReceiver<()>, + rng: Arc>, + cx: &mut TestAppContext, + ) -> anyhow::Result<()> { + while op_start_signal.next().await.is_some() { + let buffer = if client.buffers.is_empty() || rng.lock().gen() { + let worktree = if let Some(worktree) = project.read_with(cx, |project, cx| { + project + .worktrees(&cx) + .filter(|worktree| { + let worktree = worktree.read(cx); + worktree.is_visible() + && worktree.entries(false).any(|e| e.is_file()) + }) + .choose(&mut *rng.lock()) + }) { + worktree + } else { + cx.background().simulate_random_delay().await; + continue; + }; + + let (worktree_root_name, project_path) = + worktree.read_with(cx, |worktree, _| { + let entry = worktree + .entries(false) + .filter(|e| e.is_file()) + .choose(&mut *rng.lock()) + .unwrap(); + ( + worktree.root_name().to_string(), + (worktree.id(), entry.path.clone()), + ) + }); + log::info!( + "{}: opening path {:?} in worktree {} ({})", + guest_username, + project_path.1, + project_path.0, + worktree_root_name, + ); + let buffer = project + .update(cx, |project, cx| { + project.open_buffer(project_path.clone(), cx) + }) + .await?; + log::info!( + "{}: opened path {:?} in worktree {} ({}) with buffer id {}", + guest_username, + project_path.1, + project_path.0, + worktree_root_name, + buffer.read_with(cx, |buffer, _| buffer.remote_id()) + ); + client.buffers.insert(buffer.clone()); + buffer + } else { + client + .buffers + .iter() + .choose(&mut *rng.lock()) + .unwrap() + .clone() + }; + + let choice = rng.lock().gen_range(0..100); + match choice { + 0..=9 => { + cx.update(|cx| { + log::info!( + "{}: dropping buffer {:?}", + guest_username, + buffer.read(cx).file().unwrap().full_path(cx) + ); + client.buffers.remove(&buffer); + drop(buffer); + }); + } + 10..=19 => { + let completions = project.update(cx, |project, cx| { + log::info!( + "{}: requesting completions for buffer {} ({:?})", + guest_username, + buffer.read(cx).remote_id(), + buffer.read(cx).file().unwrap().full_path(cx) + ); + let offset = rng.lock().gen_range(0..=buffer.read(cx).len()); + project.completions(&buffer, offset, cx) + }); + let completions = cx.background().spawn(async move { + completions + .await + .map_err(|err| anyhow!("completions request failed: {:?}", err)) + }); + if rng.lock().gen_bool(0.3) { + log::info!("{}: detaching completions request", guest_username); + cx.update(|cx| completions.detach_and_log_err(cx)); + } else { + completions.await?; + } + } + 20..=29 => { + let code_actions = project.update(cx, |project, cx| { + log::info!( + "{}: requesting code actions for buffer {} ({:?})", + guest_username, + buffer.read(cx).remote_id(), + buffer.read(cx).file().unwrap().full_path(cx) + ); + let range = buffer.read(cx).random_byte_range(0, &mut *rng.lock()); + project.code_actions(&buffer, range, cx) + }); + let code_actions = cx.background().spawn(async move { + code_actions + .await + .map_err(|err| anyhow!("code actions request failed: {:?}", err)) + }); + if rng.lock().gen_bool(0.3) { + log::info!("{}: detaching code actions request", guest_username); + cx.update(|cx| code_actions.detach_and_log_err(cx)); + } else { + code_actions.await?; + } + } + 30..=39 if buffer.read_with(cx, |buffer, _| buffer.is_dirty()) => { + let (requested_version, save) = buffer.update(cx, |buffer, cx| { + log::info!( + "{}: saving buffer {} ({:?})", + guest_username, + buffer.remote_id(), + buffer.file().unwrap().full_path(cx) + ); + (buffer.version(), buffer.save(cx)) + }); + let save = cx.background().spawn(async move { + let (saved_version, _) = save + .await + .map_err(|err| anyhow!("save request failed: {:?}", err))?; + assert!(saved_version.observed_all(&requested_version)); + Ok::<_, anyhow::Error>(()) + }); + if rng.lock().gen_bool(0.3) { + log::info!("{}: detaching save request", guest_username); + cx.update(|cx| save.detach_and_log_err(cx)); + } else { + save.await?; + } + } + 40..=44 => { + let prepare_rename = project.update(cx, |project, cx| { + log::info!( + "{}: preparing rename for buffer {} ({:?})", + guest_username, + buffer.read(cx).remote_id(), + buffer.read(cx).file().unwrap().full_path(cx) + ); + let offset = rng.lock().gen_range(0..=buffer.read(cx).len()); + project.prepare_rename(buffer, offset, cx) + }); + let prepare_rename = cx.background().spawn(async move { + prepare_rename + .await + .map_err(|err| anyhow!("prepare rename request failed: {:?}", err)) + }); + if rng.lock().gen_bool(0.3) { + log::info!("{}: detaching prepare rename request", guest_username); + cx.update(|cx| prepare_rename.detach_and_log_err(cx)); + } else { + prepare_rename.await?; + } + } + 45..=49 => { + let definitions = project.update(cx, |project, cx| { + log::info!( + "{}: requesting definitions for buffer {} ({:?})", + guest_username, + buffer.read(cx).remote_id(), + buffer.read(cx).file().unwrap().full_path(cx) + ); + let offset = rng.lock().gen_range(0..=buffer.read(cx).len()); + project.definition(&buffer, offset, cx) + }); + let definitions = cx.background().spawn(async move { + definitions + .await + .map_err(|err| anyhow!("definitions request failed: {:?}", err)) + }); + if rng.lock().gen_bool(0.3) { + log::info!("{}: detaching definitions request", guest_username); + cx.update(|cx| definitions.detach_and_log_err(cx)); + } else { + client + .buffers + .extend(definitions.await?.into_iter().map(|loc| loc.buffer)); + } + } + 50..=54 => { + let highlights = project.update(cx, |project, cx| { + log::info!( + "{}: requesting highlights for buffer {} ({:?})", + guest_username, + buffer.read(cx).remote_id(), + buffer.read(cx).file().unwrap().full_path(cx) + ); + let offset = rng.lock().gen_range(0..=buffer.read(cx).len()); + project.document_highlights(&buffer, offset, cx) + }); + let highlights = cx.background().spawn(async move { + highlights + .await + .map_err(|err| anyhow!("highlights request failed: {:?}", err)) + }); + if rng.lock().gen_bool(0.3) { + log::info!("{}: detaching highlights request", guest_username); + cx.update(|cx| highlights.detach_and_log_err(cx)); + } else { + highlights.await?; + } + } + 55..=59 => { + let search = project.update(cx, |project, cx| { + let query = rng.lock().gen_range('a'..='z'); + log::info!("{}: project-wide search {:?}", guest_username, query); + project.search(SearchQuery::text(query, false, false), cx) + }); + let search = cx.background().spawn(async move { + search + .await + .map_err(|err| anyhow!("search request failed: {:?}", err)) + }); + if rng.lock().gen_bool(0.3) { + log::info!("{}: detaching search request", guest_username); + cx.update(|cx| search.detach_and_log_err(cx)); + } else { + client.buffers.extend(search.await?.into_keys()); + } + } + 60..=69 => { + let worktree = project + .read_with(cx, |project, cx| { + project + .worktrees(&cx) + .filter(|worktree| { + let worktree = worktree.read(cx); + worktree.is_visible() + && worktree.entries(false).any(|e| e.is_file()) + && worktree.root_entry().map_or(false, |e| e.is_dir()) + }) + .choose(&mut *rng.lock()) + }) + .unwrap(); + let (worktree_id, worktree_root_name) = worktree + .read_with(cx, |worktree, _| { + (worktree.id(), worktree.root_name().to_string()) + }); + + let mut new_name = String::new(); + for _ in 0..10 { + let letter = rng.lock().gen_range('a'..='z'); + new_name.push(letter); + } + let mut new_path = PathBuf::new(); + new_path.push(new_name); + new_path.set_extension("rs"); + log::info!( + "{}: creating {:?} in worktree {} ({})", + guest_username, + new_path, + worktree_id, + worktree_root_name, + ); + project + .update(cx, |project, cx| { + project.create_entry((worktree_id, new_path), false, cx) + }) + .unwrap() + .await?; + } + _ => { + buffer.update(cx, |buffer, cx| { + log::info!( + "{}: updating buffer {} ({:?})", + guest_username, + buffer.remote_id(), + buffer.file().unwrap().full_path(cx) + ); + if rng.lock().gen_bool(0.7) { + buffer.randomly_edit(&mut *rng.lock(), 5, cx); + } else { + buffer.randomly_undo_redo(&mut *rng.lock(), cx); + } + }); + } + } + cx.background().simulate_random_delay().await; + } + Ok(()) + } + + let result = simulate_guest_internal( + &mut self, + &guest_username, + project.clone(), + op_start_signal, + rng, + &mut cx, + ) + .await; + log::info!("{}: done", guest_username); + + self.project = Some(project); + (self, cx, result.err()) + } +} + +impl Drop for TestClient { + fn drop(&mut self) { + self.client.tear_down(); + } +} + +impl Executor for Arc { + type Sleep = gpui::executor::Timer; + + fn spawn_detached>(&self, future: F) { + self.spawn(future).detach(); + } + + fn sleep(&self, duration: Duration) -> Self::Sleep { + self.as_ref().timer(duration) + } +} + +fn channel_messages(channel: &Channel) -> Vec<(String, String, bool)> { + channel + .messages() + .cursor::<()>() + .map(|m| { + ( + m.sender.github_login.clone(), + m.body.clone(), + m.is_pending(), + ) + }) + .collect() +} + +struct EmptyView; + +impl gpui::Entity for EmptyView { + type Event = (); +} + +impl gpui::View for EmptyView { + fn ui_name() -> &'static str { + "empty view" + } + + fn render(&mut self, _: &mut gpui::RenderContext) -> gpui::ElementBox { + gpui::Element::boxed(gpui::elements::Empty::new()) + } +} diff --git a/crates/collab/src/lib.rs b/crates/collab/src/lib.rs new file mode 100644 index 0000000000000000000000000000000000000000..518530c539bdcffae03059732e5e9dba4401ac56 --- /dev/null +++ b/crates/collab/src/lib.rs @@ -0,0 +1,69 @@ +use axum::{http::StatusCode, response::IntoResponse}; + +pub type Result = std::result::Result; + +pub enum Error { + Http(StatusCode, String), + Internal(anyhow::Error), +} + +impl From for Error { + fn from(error: anyhow::Error) -> Self { + Self::Internal(error) + } +} + +impl From for Error { + fn from(error: sqlx::Error) -> Self { + Self::Internal(error.into()) + } +} + +impl From for Error { + fn from(error: axum::Error) -> Self { + Self::Internal(error.into()) + } +} + +impl From for Error { + fn from(error: hyper::Error) -> Self { + Self::Internal(error.into()) + } +} + +impl From for Error { + fn from(error: serde_json::Error) -> Self { + Self::Internal(error.into()) + } +} + +impl IntoResponse for Error { + fn into_response(self) -> axum::response::Response { + match self { + Error::Http(code, message) => (code, message).into_response(), + Error::Internal(error) => { + (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", &error)).into_response() + } + } + } +} + +impl std::fmt::Debug for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::Http(code, message) => (code, message).fmt(f), + Error::Internal(error) => error.fmt(f), + } + } +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::Http(code, message) => write!(f, "{code}: {message}"), + Error::Internal(error) => error.fmt(f), + } + } +} + +impl std::error::Error for Error {} diff --git a/crates/collab/src/main.rs b/crates/collab/src/main.rs index 784987534da5deed1cf21c2355e6a19c8f773b8b..429e666fbff280e93a0b99052b5dcccb6d3efcc2 100644 --- a/crates/collab/src/main.rs +++ b/crates/collab/src/main.rs @@ -4,7 +4,11 @@ mod db; mod env; mod rpc; -use axum::{body::Body, http::StatusCode, response::IntoResponse, Router}; +#[cfg(test)] +mod integration_tests; + +use axum::{body::Body, Router}; +use collab::{Error, Result}; use db::{Db, PostgresDb}; use serde::Deserialize; use std::{ @@ -73,74 +77,6 @@ async fn main() -> Result<()> { Ok(()) } -pub type Result = std::result::Result; - -pub enum Error { - Http(StatusCode, String), - Internal(anyhow::Error), -} - -impl From for Error { - fn from(error: anyhow::Error) -> Self { - Self::Internal(error) - } -} - -impl From for Error { - fn from(error: sqlx::Error) -> Self { - Self::Internal(error.into()) - } -} - -impl From for Error { - fn from(error: axum::Error) -> Self { - Self::Internal(error.into()) - } -} - -impl From for Error { - fn from(error: hyper::Error) -> Self { - Self::Internal(error.into()) - } -} - -impl From for Error { - fn from(error: serde_json::Error) -> Self { - Self::Internal(error.into()) - } -} - -impl IntoResponse for Error { - fn into_response(self) -> axum::response::Response { - match self { - Error::Http(code, message) => (code, message).into_response(), - Error::Internal(error) => { - (StatusCode::INTERNAL_SERVER_ERROR, format!("{}", &error)).into_response() - } - } - } -} - -impl std::fmt::Debug for Error { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Error::Http(code, message) => (code, message).fmt(f), - Error::Internal(error) => error.fmt(f), - } - } -} - -impl std::fmt::Display for Error { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Error::Http(code, message) => write!(f, "{code}: {message}"), - Error::Internal(error) => error.fmt(f), - } - } -} - -impl std::error::Error for Error {} - pub fn init_tracing(config: &Config) -> Option<()> { use opentelemetry::KeyValue; use opentelemetry_otlp::WithExportConfig; diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index ed97001e98e9f255e40738ef2dd1e104cc0d6cfd..5e0c6f7789311ee8390bf7a91e649d6d985758b7 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -47,7 +47,6 @@ use std::{ }, time::Duration, }; -use store::{Store, Worktree}; use time::OffsetDateTime; use tokio::{ sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}, @@ -56,6 +55,8 @@ use tokio::{ use tower::ServiceBuilder; use tracing::{info_span, instrument, Instrument}; +pub use store::{Store, Worktree}; + type MessageHandler = Box, Box) -> BoxFuture<'static, ()>>; @@ -80,13 +81,12 @@ impl Response { pub struct Server { peer: Arc, - store: RwLock, + pub(crate) store: RwLock, app_state: Arc, handlers: HashMap, notifications: Option>, } - pub trait Executor: Send + Clone { type Sleep: Send + Future; fn spawn_detached>(&self, future: F); @@ -120,11 +120,10 @@ pub fn serialize_deref(value: &T, serializer: S) -> Result, - U: Serialize + U: Serialize, { Serialize::serialize(value.deref(), serializer) } - impl Server { pub fn new( @@ -314,7 +313,7 @@ impl Server { let mut store = this.store_mut().await; store.add_connection(connection_id, user_id); this.peer.send(connection_id, store.build_initial_contacts_update(contacts))?; - + if let Some((code, count)) = invite_code { this.peer.send(connection_id, proto::UpdateInviteInfo { url: format!("{}{}", this.app_state.invite_link_prefix, code), @@ -437,19 +436,29 @@ impl Server { Ok(()) } - pub async fn invite_code_redeemed(self: &Arc, code: &str, invitee_id: UserId) -> Result<()> { + pub async fn invite_code_redeemed( + self: &Arc, + code: &str, + invitee_id: UserId, + ) -> Result<()> { let user = self.app_state.db.get_user_for_invite_code(code).await?; let store = self.store().await; let invitee_contact = store.contact_for_user(invitee_id, true); for connection_id in store.connection_ids_for_user(user.id) { - self.peer.send(connection_id, proto::UpdateContacts { - contacts: vec![invitee_contact.clone()], - ..Default::default() - })?; - self.peer.send(connection_id, proto::UpdateInviteInfo { - url: format!("{}{}", self.app_state.invite_link_prefix, code), - count: user.invite_count as u32, - })?; + self.peer.send( + connection_id, + proto::UpdateContacts { + contacts: vec![invitee_contact.clone()], + ..Default::default() + }, + )?; + self.peer.send( + connection_id, + proto::UpdateInviteInfo { + url: format!("{}{}", self.app_state.invite_link_prefix, code), + count: user.invite_count as u32, + }, + )?; } Ok(()) } @@ -1489,11 +1498,11 @@ impl Server { _not_send: PhantomData, } } - + pub async fn snapshot<'a>(self: &'a Arc) -> ServerSnapshot<'a> { ServerSnapshot { store: self.store.read().await, - peer: &self.peer + peer: &self.peer, } } } @@ -1684,5860 +1693,3 @@ where } } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::{ - db::{tests::TestDb, UserId}, - AppState, - }; - use ::rpc::Peer; - use client::{ - self, test::FakeHttpClient, Channel, ChannelDetails, ChannelList, Client, Credentials, - EstablishConnectionError, UserStore, RECEIVE_TIMEOUT, - }; - use collections::{BTreeMap, HashSet}; - use editor::{ - self, ConfirmCodeAction, ConfirmCompletion, ConfirmRename, Editor, Input, Redo, Rename, - ToOffset, ToggleCodeActions, Undo, - }; - use gpui::{ - executor::{self, Deterministic}, - geometry::vector::vec2f, - ModelHandle, Task, TestAppContext, ViewHandle, - }; - use language::{ - range_to_lsp, tree_sitter_rust, Diagnostic, DiagnosticEntry, FakeLspAdapter, Language, - LanguageConfig, LanguageRegistry, OffsetRangeExt, Point, Rope, - }; - use lsp::{self, FakeLanguageServer}; - use parking_lot::Mutex; - use project::{ - fs::{FakeFs, Fs as _}, - search::SearchQuery, - worktree::WorktreeHandle, - DiagnosticSummary, Project, ProjectPath, WorktreeId, - }; - use rand::prelude::*; - use rpc::PeerId; - use serde_json::json; - use settings::Settings; - use sqlx::types::time::OffsetDateTime; - use std::{ - cell::RefCell, - env, - ops::Deref, - path::{Path, PathBuf}, - rc::Rc, - sync::{ - atomic::{AtomicBool, Ordering::SeqCst}, - Arc, - }, - time::Duration, - }; - use theme::ThemeRegistry; - use workspace::{Item, SplitDirection, ToggleFollow, Workspace}; - - #[cfg(test)] - #[ctor::ctor] - fn init_logger() { - if std::env::var("RUST_LOG").is_ok() { - env_logger::init(); - } - } - - #[gpui::test(iterations = 10)] - async fn test_share_project( - deterministic: Arc, - cx_a: &mut TestAppContext, - cx_b: &mut TestAppContext, - cx_b2: &mut TestAppContext, - ) { - let (window_b, _) = cx_b.add_window(|_| EmptyView); - let lang_registry = Arc::new(LanguageRegistry::test()); - let fs = FakeFs::new(cx_a.background()); - cx_a.foreground().forbid_parking(); - - // Connect to a server as 2 clients. - let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; - let client_a = server.create_client(cx_a, "user_a").await; - let mut client_b = server.create_client(cx_b, "user_b").await; - server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) - .await; - - // Share a project as 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; - let project_a = cx_a.update(|cx| { - Project::local( - client_a.clone(), - client_a.user_store.clone(), - lang_registry.clone(), - fs.clone(), - cx, - ) - }); - let project_id = project_a - .read_with(cx_a, |project, _| project.next_remote_id()) - .await; - let (worktree_a, _) = project_a - .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/a", true, cx) - }) - .await - .unwrap(); - let worktree_id = worktree_a.read_with(cx_a, |tree, _| tree.id()); - worktree_a - .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete()) - .await; - - // Join that project as client B - let client_b_peer_id = client_b.peer_id; - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; - let replica_id_b = project_b.read_with(cx_b, |project, _| { - assert_eq!( - project - .collaborators() - .get(&client_a.peer_id) - .unwrap() - .user - .github_login, - "user_a" - ); - project.replica_id() - }); - - deterministic.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); - assert_eq!(client_b_collaborator.user.github_login, "user_b"); - }); - project_b.read_with(cx_b, |project, cx| { - let worktree = project.worktrees(cx).next().unwrap().read(cx); - assert_eq!( - worktree.paths().map(AsRef::as_ref).collect::>(), - [ - 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.add_view(window_b, |cx| Editor::for_buffer(buffer_b, None, cx)); - - // TODO - // // Create a selection set as client B and see that selection set as client A. - // buffer_a - // .condition(&cx_a, |buffer, _| buffer.selection_sets().count() == 1) - // .await; - - // Edit the buffer as client B and see that edit as client A. - editor_b.update(cx_b, |editor, cx| { - editor.handle_input(&Input("ok, ".into()), cx) - }); - buffer_a - .condition(&cx_a, |buffer, _| buffer.text() == "ok, b-contents") - .await; - - // TODO - // // Remove the selection set as client B, see those selections disappear as client A. - cx_b.update(move |_| drop(editor_b)); - // buffer_a - // .condition(&cx_a, |buffer, _| buffer.selection_sets().count() == 0) - // .await; - - // Client B can join again on a different window because they are already a participant. - let client_b2 = server.create_client(cx_b2, "user_b").await; - let project_b2 = Project::remote( - project_id, - client_b2.client.clone(), - client_b2.user_store.clone(), - lang_registry.clone(), - FakeFs::new(cx_b2.background()), - &mut cx_b2.to_async(), - ) - .await - .unwrap(); - deterministic.run_until_parked(); - project_a.read_with(cx_a, |project, _| { - assert_eq!(project.collaborators().len(), 2); - }); - project_b.read_with(cx_b, |project, _| { - assert_eq!(project.collaborators().len(), 2); - }); - project_b2.read_with(cx_b2, |project, _| { - assert_eq!(project.collaborators().len(), 2); - }); - - // Dropping client B's first project removes only that from client A's collaborators. - cx_b.update(move |_| { - drop(client_b.project.take()); - drop(project_b); - }); - deterministic.run_until_parked(); - project_a.read_with(cx_a, |project, _| { - assert_eq!(project.collaborators().len(), 1); - }); - project_b2.read_with(cx_b2, |project, _| { - assert_eq!(project.collaborators().len(), 1); - }); - } - - #[gpui::test(iterations = 10)] - async fn test_unshare_project( - deterministic: Arc, - cx_a: &mut TestAppContext, - cx_b: &mut TestAppContext, - ) { - let lang_registry = Arc::new(LanguageRegistry::test()); - let fs = FakeFs::new(cx_a.background()); - cx_a.foreground().forbid_parking(); - - // Connect to a server as 2 clients. - let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; - let client_a = server.create_client(cx_a, "user_a").await; - let mut client_b = server.create_client(cx_b, "user_b").await; - server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) - .await; - - // Share a project as client A - fs.insert_tree( - "/a", - json!({ - "a.txt": "a-contents", - "b.txt": "b-contents", - }), - ) - .await; - let project_a = cx_a.update(|cx| { - Project::local( - client_a.clone(), - client_a.user_store.clone(), - lang_registry.clone(), - fs.clone(), - cx, - ) - }); - let (worktree_a, _) = project_a - .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/a", true, cx) - }) - .await - .unwrap(); - worktree_a - .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete()) - .await; - let worktree_id = worktree_a.read_with(cx_a, |tree, _| tree.id()); - - // Join that project as client B - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; - assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared())); - project_b - .update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx)) - .await - .unwrap(); - - // When client B leaves the project, it gets automatically unshared. - cx_b.update(|_| { - drop(client_b.project.take()); - drop(project_b); - }); - deterministic.run_until_parked(); - assert!(worktree_a.read_with(cx_a, |tree, _| !tree.as_local().unwrap().is_shared())); - - // When client B joins again, the project gets re-shared. - let project_b2 = client_b.build_remote_project(&project_a, cx_a, cx_b).await; - assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared())); - project_b2 - .update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx)) - .await - .unwrap(); - - // When client A (the host) leaves, the project gets unshared and guests are notified. - cx_a.update(|_| drop(project_a)); - deterministic.run_until_parked(); - project_b2.read_with(cx_b, |project, _| { - assert!(project.is_read_only()); - assert!(project.collaborators().is_empty()); - }); - } - - #[gpui::test(iterations = 10)] - async fn test_host_disconnect( - deterministic: Arc, - cx_a: &mut TestAppContext, - cx_b: &mut TestAppContext, - cx_c: &mut TestAppContext, - ) { - let lang_registry = Arc::new(LanguageRegistry::test()); - let fs = FakeFs::new(cx_a.background()); - cx_a.foreground().forbid_parking(); - - // Connect to a server as 3 clients. - let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; - let client_a = server.create_client(cx_a, "user_a").await; - let mut client_b = server.create_client(cx_b, "user_b").await; - let client_c = server.create_client(cx_c, "user_c").await; - server - .make_contacts(vec![ - (&client_a, cx_a), - (&client_b, cx_b), - (&client_c, cx_c), - ]) - .await; - - // Share a project as client A - fs.insert_tree( - "/a", - json!({ - "a.txt": "a-contents", - "b.txt": "b-contents", - }), - ) - .await; - let project_a = cx_a.update(|cx| { - Project::local( - client_a.clone(), - client_a.user_store.clone(), - lang_registry.clone(), - fs.clone(), - cx, - ) - }); - let project_id = project_a - .read_with(cx_a, |project, _| project.next_remote_id()) - .await; - let (worktree_a, _) = project_a - .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/a", true, cx) - }) - .await - .unwrap(); - worktree_a - .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete()) - .await; - let worktree_id = worktree_a.read_with(cx_a, |tree, _| tree.id()); - - // Join that project as client B - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; - assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared())); - project_b - .update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx)) - .await - .unwrap(); - - // Request to join that project as client C - let project_c = cx_c.spawn(|mut cx| { - let client = client_c.client.clone(); - let user_store = client_c.user_store.clone(); - let lang_registry = lang_registry.clone(); - async move { - Project::remote( - project_id, - client, - user_store, - lang_registry.clone(), - FakeFs::new(cx.background()), - &mut cx, - ) - .await - } - }); - deterministic.run_until_parked(); - - // Drop client A's connection. Collaborators should disappear and the project should not be shown as shared. - server.disconnect_client(client_a.current_user_id(cx_a)); - cx_a.foreground().advance_clock(rpc::RECEIVE_TIMEOUT); - project_a - .condition(cx_a, |project, _| project.collaborators().is_empty()) - .await; - project_a.read_with(cx_a, |project, _| assert!(!project.is_shared())); - project_b - .condition(cx_b, |project, _| project.is_read_only()) - .await; - assert!(worktree_a.read_with(cx_a, |tree, _| !tree.as_local().unwrap().is_shared())); - cx_b.update(|_| { - drop(project_b); - }); - assert!(matches!( - project_c.await.unwrap_err(), - project::JoinProjectError::HostWentOffline - )); - - // Ensure guests can still join. - let project_b2 = client_b.build_remote_project(&project_a, cx_a, cx_b).await; - assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared())); - project_b2 - .update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx)) - .await - .unwrap(); - } - - #[gpui::test(iterations = 10)] - async fn test_decline_join_request( - deterministic: Arc, - cx_a: &mut TestAppContext, - cx_b: &mut TestAppContext, - ) { - let lang_registry = Arc::new(LanguageRegistry::test()); - let fs = FakeFs::new(cx_a.background()); - cx_a.foreground().forbid_parking(); - - // Connect to a server as 2 clients. - let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; - let client_a = server.create_client(cx_a, "user_a").await; - let client_b = server.create_client(cx_b, "user_b").await; - server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) - .await; - - // Share a project as client A - fs.insert_tree("/a", json!({})).await; - let project_a = cx_a.update(|cx| { - Project::local( - client_a.clone(), - client_a.user_store.clone(), - lang_registry.clone(), - fs.clone(), - cx, - ) - }); - let project_id = project_a - .read_with(cx_a, |project, _| project.next_remote_id()) - .await; - let (worktree_a, _) = project_a - .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/a", true, cx) - }) - .await - .unwrap(); - worktree_a - .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete()) - .await; - - // Request to join that project as client B - let project_b = cx_b.spawn(|mut cx| { - let client = client_b.client.clone(); - let user_store = client_b.user_store.clone(); - let lang_registry = lang_registry.clone(); - async move { - Project::remote( - project_id, - client, - user_store, - lang_registry.clone(), - FakeFs::new(cx.background()), - &mut cx, - ) - .await - } - }); - deterministic.run_until_parked(); - project_a.update(cx_a, |project, cx| { - project.respond_to_join_request(client_b.user_id().unwrap(), false, cx) - }); - assert!(matches!( - project_b.await.unwrap_err(), - project::JoinProjectError::HostDeclined - )); - - // Request to join the project again as client B - let project_b = cx_b.spawn(|mut cx| { - let client = client_b.client.clone(); - let user_store = client_b.user_store.clone(); - let lang_registry = lang_registry.clone(); - async move { - Project::remote( - project_id, - client, - user_store, - lang_registry.clone(), - FakeFs::new(cx.background()), - &mut cx, - ) - .await - } - }); - - // Close the project on the host - deterministic.run_until_parked(); - cx_a.update(|_| drop(project_a)); - deterministic.run_until_parked(); - assert!(matches!( - project_b.await.unwrap_err(), - project::JoinProjectError::HostClosedProject - )); - } - - #[gpui::test(iterations = 10)] - async fn test_cancel_join_request( - deterministic: Arc, - cx_a: &mut TestAppContext, - cx_b: &mut TestAppContext, - ) { - let lang_registry = Arc::new(LanguageRegistry::test()); - let fs = FakeFs::new(cx_a.background()); - cx_a.foreground().forbid_parking(); - - // Connect to a server as 2 clients. - let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; - let client_a = server.create_client(cx_a, "user_a").await; - let client_b = server.create_client(cx_b, "user_b").await; - server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) - .await; - - // Share a project as client A - fs.insert_tree("/a", json!({})).await; - let project_a = cx_a.update(|cx| { - Project::local( - client_a.clone(), - client_a.user_store.clone(), - lang_registry.clone(), - fs.clone(), - cx, - ) - }); - let project_id = project_a - .read_with(cx_a, |project, _| project.next_remote_id()) - .await; - - let project_a_events = Rc::new(RefCell::new(Vec::new())); - let user_b = client_a - .user_store - .update(cx_a, |store, cx| { - store.fetch_user(client_b.user_id().unwrap(), cx) - }) - .await - .unwrap(); - project_a.update(cx_a, { - let project_a_events = project_a_events.clone(); - move |_, cx| { - cx.subscribe(&cx.handle(), move |_, _, event, _| { - project_a_events.borrow_mut().push(event.clone()); - }) - .detach(); - } - }); - - let (worktree_a, _) = project_a - .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/a", true, cx) - }) - .await - .unwrap(); - worktree_a - .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete()) - .await; - - // Request to join that project as client B - let project_b = cx_b.spawn(|mut cx| { - let client = client_b.client.clone(); - let user_store = client_b.user_store.clone(); - let lang_registry = lang_registry.clone(); - async move { - Project::remote( - project_id, - client, - user_store, - lang_registry.clone(), - FakeFs::new(cx.background()), - &mut cx, - ) - .await - } - }); - deterministic.run_until_parked(); - assert_eq!( - &*project_a_events.borrow(), - &[project::Event::ContactRequestedJoin(user_b.clone())] - ); - project_a_events.borrow_mut().clear(); - - // Cancel the join request by leaving the project - client_b - .client - .send(proto::LeaveProject { project_id }) - .unwrap(); - drop(project_b); - - deterministic.run_until_parked(); - assert_eq!( - &*project_a_events.borrow(), - &[project::Event::ContactCancelledJoinRequest(user_b.clone())] - ); - } - - #[gpui::test(iterations = 10)] - async fn test_propagate_saves_and_fs_changes( - cx_a: &mut TestAppContext, - cx_b: &mut TestAppContext, - cx_c: &mut TestAppContext, - ) { - let lang_registry = Arc::new(LanguageRegistry::test()); - let fs = FakeFs::new(cx_a.background()); - cx_a.foreground().forbid_parking(); - - // Connect to a server as 3 clients. - let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; - let client_a = server.create_client(cx_a, "user_a").await; - let mut client_b = server.create_client(cx_b, "user_b").await; - let mut client_c = server.create_client(cx_c, "user_c").await; - server - .make_contacts(vec![ - (&client_a, cx_a), - (&client_b, cx_b), - (&client_c, cx_c), - ]) - .await; - - // Share a worktree as client A. - fs.insert_tree( - "/a", - json!({ - "file1": "", - "file2": "" - }), - ) - .await; - let project_a = cx_a.update(|cx| { - Project::local( - client_a.clone(), - client_a.user_store.clone(), - lang_registry.clone(), - fs.clone(), - cx, - ) - }); - let (worktree_a, _) = project_a - .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/a", true, cx) - }) - .await - .unwrap(); - worktree_a - .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete()) - .await; - let worktree_id = worktree_a.read_with(cx_a, |tree, _| tree.id()); - - // Join that worktree as clients B and C. - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; - let project_c = client_c.build_remote_project(&project_a, cx_a, cx_c).await; - let worktree_b = project_b.read_with(cx_b, |p, cx| p.worktrees(cx).next().unwrap()); - let worktree_c = project_c.read_with(cx_c, |p, cx| p.worktrees(cx).next().unwrap()); - - // Open and edit a buffer as both guests B and C. - let buffer_b = project_b - .update(cx_b, |p, cx| p.open_buffer((worktree_id, "file1"), cx)) - .await - .unwrap(); - let buffer_c = project_c - .update(cx_c, |p, cx| p.open_buffer((worktree_id, "file1"), cx)) - .await - .unwrap(); - buffer_b.update(cx_b, |buf, cx| buf.edit([(0..0, "i-am-b, ")], cx)); - buffer_c.update(cx_c, |buf, cx| buf.edit([(0..0, "i-am-c, ")], cx)); - - // Open and edit that buffer as the host. - let buffer_a = project_a - .update(cx_a, |p, cx| p.open_buffer((worktree_id, "file1"), cx)) - .await - .unwrap(); - - buffer_a - .condition(cx_a, |buf, _| buf.text() == "i-am-c, i-am-b, ") - .await; - buffer_a.update(cx_a, |buf, cx| { - buf.edit([(buf.len()..buf.len(), "i-am-a")], cx) - }); - - // Wait for edits to propagate - buffer_a - .condition(cx_a, |buf, _| buf.text() == "i-am-c, i-am-b, i-am-a") - .await; - buffer_b - .condition(cx_b, |buf, _| buf.text() == "i-am-c, i-am-b, i-am-a") - .await; - buffer_c - .condition(cx_c, |buf, _| buf.text() == "i-am-c, i-am-b, i-am-a") - .await; - - // Edit the buffer as the host and concurrently save as guest B. - let save_b = buffer_b.update(cx_b, |buf, cx| buf.save(cx)); - buffer_a.update(cx_a, |buf, cx| buf.edit([(0..0, "hi-a, ")], cx)); - save_b.await.unwrap(); - assert_eq!( - fs.load("/a/file1".as_ref()).await.unwrap(), - "hi-a, i-am-c, i-am-b, i-am-a" - ); - buffer_a.read_with(cx_a, |buf, _| assert!(!buf.is_dirty())); - buffer_b.read_with(cx_b, |buf, _| assert!(!buf.is_dirty())); - buffer_c.condition(cx_c, |buf, _| !buf.is_dirty()).await; - - worktree_a.flush_fs_events(cx_a).await; - - // Make changes on host's file system, see those changes on guest worktrees. - fs.rename( - "/a/file1".as_ref(), - "/a/file1-renamed".as_ref(), - Default::default(), - ) - .await - .unwrap(); - - fs.rename("/a/file2".as_ref(), "/a/file3".as_ref(), Default::default()) - .await - .unwrap(); - fs.insert_file(Path::new("/a/file4"), "4".into()).await; - - worktree_a - .condition(&cx_a, |tree, _| { - tree.paths() - .map(|p| p.to_string_lossy()) - .collect::>() - == ["file1-renamed", "file3", "file4"] - }) - .await; - worktree_b - .condition(&cx_b, |tree, _| { - tree.paths() - .map(|p| p.to_string_lossy()) - .collect::>() - == ["file1-renamed", "file3", "file4"] - }) - .await; - worktree_c - .condition(&cx_c, |tree, _| { - tree.paths() - .map(|p| p.to_string_lossy()) - .collect::>() - == ["file1-renamed", "file3", "file4"] - }) - .await; - - // Ensure buffer files are updated as well. - buffer_a - .condition(&cx_a, |buf, _| { - buf.file().unwrap().path().to_str() == Some("file1-renamed") - }) - .await; - buffer_b - .condition(&cx_b, |buf, _| { - buf.file().unwrap().path().to_str() == Some("file1-renamed") - }) - .await; - buffer_c - .condition(&cx_c, |buf, _| { - buf.file().unwrap().path().to_str() == Some("file1-renamed") - }) - .await; - } - - #[gpui::test(iterations = 10)] - async fn test_fs_operations( - executor: Arc, - cx_a: &mut TestAppContext, - cx_b: &mut TestAppContext, - ) { - executor.forbid_parking(); - let fs = FakeFs::new(cx_a.background()); - - // Connect to a server as 2 clients. - let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; - let mut client_a = server.create_client(cx_a, "user_a").await; - let mut client_b = server.create_client(cx_b, "user_b").await; - server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) - .await; - - // Share a project as client A - fs.insert_tree( - "/dir", - json!({ - "a.txt": "a-contents", - "b.txt": "b-contents", - }), - ) - .await; - - let (project_a, worktree_id) = client_a.build_local_project(fs, "/dir", cx_a).await; - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; - - let worktree_a = - project_a.read_with(cx_a, |project, cx| project.worktrees(cx).next().unwrap()); - let worktree_b = - project_b.read_with(cx_b, |project, cx| project.worktrees(cx).next().unwrap()); - - let entry = project_b - .update(cx_b, |project, cx| { - project - .create_entry((worktree_id, "c.txt"), false, cx) - .unwrap() - }) - .await - .unwrap(); - worktree_a.read_with(cx_a, |worktree, _| { - assert_eq!( - worktree - .paths() - .map(|p| p.to_string_lossy()) - .collect::>(), - ["a.txt", "b.txt", "c.txt"] - ); - }); - worktree_b.read_with(cx_b, |worktree, _| { - assert_eq!( - worktree - .paths() - .map(|p| p.to_string_lossy()) - .collect::>(), - ["a.txt", "b.txt", "c.txt"] - ); - }); - - project_b - .update(cx_b, |project, cx| { - project.rename_entry(entry.id, Path::new("d.txt"), cx) - }) - .unwrap() - .await - .unwrap(); - worktree_a.read_with(cx_a, |worktree, _| { - assert_eq!( - worktree - .paths() - .map(|p| p.to_string_lossy()) - .collect::>(), - ["a.txt", "b.txt", "d.txt"] - ); - }); - worktree_b.read_with(cx_b, |worktree, _| { - assert_eq!( - worktree - .paths() - .map(|p| p.to_string_lossy()) - .collect::>(), - ["a.txt", "b.txt", "d.txt"] - ); - }); - - let dir_entry = project_b - .update(cx_b, |project, cx| { - project - .create_entry((worktree_id, "DIR"), true, cx) - .unwrap() - }) - .await - .unwrap(); - worktree_a.read_with(cx_a, |worktree, _| { - assert_eq!( - worktree - .paths() - .map(|p| p.to_string_lossy()) - .collect::>(), - ["DIR", "a.txt", "b.txt", "d.txt"] - ); - }); - worktree_b.read_with(cx_b, |worktree, _| { - assert_eq!( - worktree - .paths() - .map(|p| p.to_string_lossy()) - .collect::>(), - ["DIR", "a.txt", "b.txt", "d.txt"] - ); - }); - - project_b - .update(cx_b, |project, cx| { - project - .create_entry((worktree_id, "DIR/e.txt"), false, cx) - .unwrap() - }) - .await - .unwrap(); - project_b - .update(cx_b, |project, cx| { - project - .create_entry((worktree_id, "DIR/SUBDIR"), true, cx) - .unwrap() - }) - .await - .unwrap(); - project_b - .update(cx_b, |project, cx| { - project - .create_entry((worktree_id, "DIR/SUBDIR/f.txt"), false, cx) - .unwrap() - }) - .await - .unwrap(); - worktree_a.read_with(cx_a, |worktree, _| { - assert_eq!( - worktree - .paths() - .map(|p| p.to_string_lossy()) - .collect::>(), - ["DIR", "DIR/SUBDIR", "DIR/SUBDIR/f.txt", "DIR/e.txt", "a.txt", "b.txt", "d.txt"] - ); - }); - worktree_b.read_with(cx_b, |worktree, _| { - assert_eq!( - worktree - .paths() - .map(|p| p.to_string_lossy()) - .collect::>(), - ["DIR", "DIR/SUBDIR", "DIR/SUBDIR/f.txt", "DIR/e.txt", "a.txt", "b.txt", "d.txt"] - ); - }); - - project_b - .update(cx_b, |project, cx| { - project - .copy_entry(dir_entry.id, Path::new("DIR2"), cx) - .unwrap() - }) - .await - .unwrap(); - worktree_a.read_with(cx_a, |worktree, _| { - assert_eq!( - worktree - .paths() - .map(|p| p.to_string_lossy()) - .collect::>(), - ["DIR", "DIR/SUBDIR", "DIR/SUBDIR/f.txt", "DIR/e.txt", "DIR2", "DIR2/SUBDIR", "DIR2/SUBDIR/f.txt", "DIR2/e.txt", "a.txt", "b.txt", "d.txt"] - ); - }); - worktree_b.read_with(cx_b, |worktree, _| { - assert_eq!( - worktree - .paths() - .map(|p| p.to_string_lossy()) - .collect::>(), - ["DIR", "DIR/SUBDIR", "DIR/SUBDIR/f.txt", "DIR/e.txt", "DIR2", "DIR2/SUBDIR", "DIR2/SUBDIR/f.txt", "DIR2/e.txt", "a.txt", "b.txt", "d.txt"] - ); - }); - - project_b - .update(cx_b, |project, cx| { - project.delete_entry(dir_entry.id, cx).unwrap() - }) - .await - .unwrap(); - worktree_a.read_with(cx_a, |worktree, _| { - assert_eq!( - worktree - .paths() - .map(|p| p.to_string_lossy()) - .collect::>(), - ["DIR2", "DIR2/SUBDIR", "DIR2/SUBDIR/f.txt", "DIR2/e.txt", "a.txt", "b.txt", "d.txt"] - ); - }); - worktree_b.read_with(cx_b, |worktree, _| { - assert_eq!( - worktree - .paths() - .map(|p| p.to_string_lossy()) - .collect::>(), - ["DIR2", "DIR2/SUBDIR", "DIR2/SUBDIR/f.txt", "DIR2/e.txt", "a.txt", "b.txt", "d.txt"] - ); - }); - - project_b - .update(cx_b, |project, cx| { - project.delete_entry(entry.id, cx).unwrap() - }) - .await - .unwrap(); - worktree_a.read_with(cx_a, |worktree, _| { - assert_eq!( - worktree - .paths() - .map(|p| p.to_string_lossy()) - .collect::>(), - ["DIR2", "DIR2/SUBDIR", "DIR2/SUBDIR/f.txt", "DIR2/e.txt", "a.txt", "b.txt"] - ); - }); - worktree_b.read_with(cx_b, |worktree, _| { - assert_eq!( - worktree - .paths() - .map(|p| p.to_string_lossy()) - .collect::>(), - ["DIR2", "DIR2/SUBDIR", "DIR2/SUBDIR/f.txt", "DIR2/e.txt", "a.txt", "b.txt"] - ); - }); - } - - #[gpui::test(iterations = 10)] - async fn test_buffer_conflict_after_save(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { - cx_a.foreground().forbid_parking(); - let lang_registry = Arc::new(LanguageRegistry::test()); - let fs = FakeFs::new(cx_a.background()); - - // Connect to a server as 2 clients. - let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; - let client_a = server.create_client(cx_a, "user_a").await; - let mut client_b = server.create_client(cx_b, "user_b").await; - server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) - .await; - - // Share a project as client A - fs.insert_tree( - "/dir", - json!({ - "a.txt": "a-contents", - }), - ) - .await; - - let project_a = cx_a.update(|cx| { - Project::local( - client_a.clone(), - client_a.user_store.clone(), - lang_registry.clone(), - fs.clone(), - cx, - ) - }); - let (worktree_a, _) = project_a - .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/dir", true, cx) - }) - .await - .unwrap(); - worktree_a - .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete()) - .await; - let worktree_id = worktree_a.read_with(cx_a, |tree, _| tree.id()); - - // Join that project as client B - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; - - // 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(); - - buffer_b.update(cx_b, |buf, cx| buf.edit([(0..0, "world ")], cx)); - buffer_b.read_with(cx_b, |buf, _| { - assert!(buf.is_dirty()); - assert!(!buf.has_conflict()); - }); - - buffer_b.update(cx_b, |buf, cx| buf.save(cx)).await.unwrap(); - buffer_b - .condition(&cx_b, |buffer_b, _| !buffer_b.is_dirty()) - .await; - buffer_b.read_with(cx_b, |buf, _| { - assert!(!buf.has_conflict()); - }); - - buffer_b.update(cx_b, |buf, cx| buf.edit([(0..0, "hello ")], cx)); - buffer_b.read_with(cx_b, |buf, _| { - assert!(buf.is_dirty()); - assert!(!buf.has_conflict()); - }); - } - - #[gpui::test(iterations = 10)] - async fn test_buffer_reloading(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { - cx_a.foreground().forbid_parking(); - let lang_registry = Arc::new(LanguageRegistry::test()); - let fs = FakeFs::new(cx_a.background()); - - // Connect to a server as 2 clients. - let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; - let client_a = server.create_client(cx_a, "user_a").await; - let mut client_b = server.create_client(cx_b, "user_b").await; - server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) - .await; - - // Share a project as client A - fs.insert_tree( - "/dir", - json!({ - "a.txt": "a-contents", - }), - ) - .await; - - let project_a = cx_a.update(|cx| { - Project::local( - client_a.clone(), - client_a.user_store.clone(), - lang_registry.clone(), - fs.clone(), - cx, - ) - }); - let (worktree_a, _) = project_a - .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/dir", true, cx) - }) - .await - .unwrap(); - worktree_a - .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete()) - .await; - let worktree_id = worktree_a.read_with(cx_a, |tree, _| tree.id()); - - // Join that project as client B - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; - let _worktree_b = project_b.update(cx_b, |p, cx| p.worktrees(cx).next().unwrap()); - - // 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(); - buffer_b.read_with(cx_b, |buf, _| { - assert!(!buf.is_dirty()); - assert!(!buf.has_conflict()); - }); - - fs.save(Path::new("/dir/a.txt"), &"new contents".into()) - .await - .unwrap(); - buffer_b - .condition(&cx_b, |buf, _| { - buf.text() == "new contents" && !buf.is_dirty() - }) - .await; - buffer_b.read_with(cx_b, |buf, _| { - assert!(!buf.has_conflict()); - }); - } - - #[gpui::test(iterations = 10)] - async fn test_editing_while_guest_opens_buffer( - cx_a: &mut TestAppContext, - cx_b: &mut TestAppContext, - ) { - cx_a.foreground().forbid_parking(); - let lang_registry = Arc::new(LanguageRegistry::test()); - let fs = FakeFs::new(cx_a.background()); - - // Connect to a server as 2 clients. - let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; - let client_a = server.create_client(cx_a, "user_a").await; - let mut client_b = server.create_client(cx_b, "user_b").await; - server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) - .await; - - // Share a project as client A - fs.insert_tree( - "/dir", - json!({ - "a.txt": "a-contents", - }), - ) - .await; - let project_a = cx_a.update(|cx| { - Project::local( - client_a.clone(), - client_a.user_store.clone(), - lang_registry.clone(), - fs.clone(), - cx, - ) - }); - let (worktree_a, _) = project_a - .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/dir", true, cx) - }) - .await - .unwrap(); - worktree_a - .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete()) - .await; - let worktree_id = worktree_a.read_with(cx_a, |tree, _| tree.id()); - - // Join that project as client B - let project_b = client_b.build_remote_project(&project_a, cx_a, 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(); - - // Start opening the same buffer as client B - let buffer_b = cx_b - .background() - .spawn(project_b.update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx))); - - // Edit the buffer as client A while client B is still opening it. - cx_b.background().simulate_random_delay().await; - buffer_a.update(cx_a, |buf, cx| buf.edit([(0..0, "X")], cx)); - cx_b.background().simulate_random_delay().await; - buffer_a.update(cx_a, |buf, cx| buf.edit([(1..1, "Y")], cx)); - - let text = buffer_a.read_with(cx_a, |buf, _| buf.text()); - let buffer_b = buffer_b.await.unwrap(); - buffer_b.condition(&cx_b, |buf, _| buf.text() == text).await; - } - - #[gpui::test(iterations = 10)] - async fn test_leaving_worktree_while_opening_buffer( - cx_a: &mut TestAppContext, - cx_b: &mut TestAppContext, - ) { - cx_a.foreground().forbid_parking(); - let lang_registry = Arc::new(LanguageRegistry::test()); - let fs = FakeFs::new(cx_a.background()); - - // Connect to a server as 2 clients. - let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; - let client_a = server.create_client(cx_a, "user_a").await; - let mut client_b = server.create_client(cx_b, "user_b").await; - server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) - .await; - - // Share a project as client A - fs.insert_tree( - "/dir", - json!({ - "a.txt": "a-contents", - }), - ) - .await; - let project_a = cx_a.update(|cx| { - Project::local( - client_a.clone(), - client_a.user_store.clone(), - lang_registry.clone(), - fs.clone(), - cx, - ) - }); - let (worktree_a, _) = project_a - .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/dir", true, cx) - }) - .await - .unwrap(); - worktree_a - .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete()) - .await; - let worktree_id = worktree_a.read_with(cx_a, |tree, _| tree.id()); - - // Join that project as client B - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; - - // See that a guest has joined as client A. - project_a - .condition(&cx_a, |p, _| p.collaborators().len() == 1) - .await; - - // Begin opening a buffer as client B, but leave the project before the open completes. - let buffer_b = cx_b - .background() - .spawn(project_b.update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx))); - cx_b.update(|_| { - drop(client_b.project.take()); - drop(project_b); - }); - drop(buffer_b); - - // See that the guest has left. - project_a - .condition(&cx_a, |p, _| p.collaborators().len() == 0) - .await; - } - - #[gpui::test(iterations = 10)] - async fn test_leaving_project(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { - cx_a.foreground().forbid_parking(); - let lang_registry = Arc::new(LanguageRegistry::test()); - let fs = FakeFs::new(cx_a.background()); - - // Connect to a server as 2 clients. - let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; - let client_a = server.create_client(cx_a, "user_a").await; - let mut client_b = server.create_client(cx_b, "user_b").await; - server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) - .await; - - // Share a project as client A - fs.insert_tree( - "/a", - json!({ - "a.txt": "a-contents", - "b.txt": "b-contents", - }), - ) - .await; - let project_a = cx_a.update(|cx| { - Project::local( - client_a.clone(), - client_a.user_store.clone(), - lang_registry.clone(), - fs.clone(), - cx, - ) - }); - let (worktree_a, _) = project_a - .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/a", true, cx) - }) - .await - .unwrap(); - worktree_a - .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete()) - .await; - - // Join that project as client B - let _project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; - - // Client A sees that a guest has joined. - project_a - .condition(cx_a, |p, _| p.collaborators().len() == 1) - .await; - - // Drop client B's connection and ensure client A observes client B leaving the project. - client_b.disconnect(&cx_b.to_async()).unwrap(); - project_a - .condition(cx_a, |p, _| p.collaborators().len() == 0) - .await; - - // Rejoin the project as client B - let _project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; - - // Client A sees that a guest has re-joined. - project_a - .condition(cx_a, |p, _| p.collaborators().len() == 1) - .await; - - // Simulate connection loss for client B and ensure client A observes client B leaving the project. - client_b.wait_for_current_user(cx_b).await; - server.disconnect_client(client_b.current_user_id(cx_b)); - cx_a.foreground().advance_clock(rpc::RECEIVE_TIMEOUT); - project_a - .condition(cx_a, |p, _| p.collaborators().len() == 0) - .await; - } - - #[gpui::test(iterations = 10)] - async fn test_collaborating_with_diagnostics( - deterministic: Arc, - cx_a: &mut TestAppContext, - cx_b: &mut TestAppContext, - cx_c: &mut TestAppContext, - ) { - deterministic.forbid_parking(); - let lang_registry = Arc::new(LanguageRegistry::test()); - let fs = FakeFs::new(cx_a.background()); - - // Set up a fake language server. - let mut language = Language::new( - LanguageConfig { - name: "Rust".into(), - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - Some(tree_sitter_rust::language()), - ); - let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()); - lang_registry.add(Arc::new(language)); - - // Connect to a server as 2 clients. - let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; - let client_a = server.create_client(cx_a, "user_a").await; - let mut client_b = server.create_client(cx_b, "user_b").await; - let mut client_c = server.create_client(cx_c, "user_c").await; - server - .make_contacts(vec![ - (&client_a, cx_a), - (&client_b, cx_b), - (&client_c, cx_c), - ]) - .await; - - // Share a project as client A - fs.insert_tree( - "/a", - json!({ - "a.rs": "let one = two", - "other.rs": "", - }), - ) - .await; - let project_a = cx_a.update(|cx| { - Project::local( - client_a.clone(), - client_a.user_store.clone(), - lang_registry.clone(), - fs.clone(), - cx, - ) - }); - let (worktree_a, _) = project_a - .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/a", true, cx) - }) - .await - .unwrap(); - worktree_a - .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete()) - .await; - let project_id = project_a.update(cx_a, |p, _| p.next_remote_id()).await; - let worktree_id = worktree_a.read_with(cx_a, |tree, _| tree.id()); - - // Cause the language server to start. - let _buffer = cx_a - .background() - .spawn(project_a.update(cx_a, |project, cx| { - project.open_buffer( - ProjectPath { - worktree_id, - path: Path::new("other.rs").into(), - }, - cx, - ) - })) - .await - .unwrap(); - - // Join the worktree as client B. - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; - - // Simulate a language server reporting errors for a file. - let mut fake_language_server = fake_language_servers.next().await.unwrap(); - fake_language_server - .receive_notification::() - .await; - fake_language_server.notify::( - lsp::PublishDiagnosticsParams { - uri: lsp::Url::from_file_path("/a/a.rs").unwrap(), - version: None, - diagnostics: vec![lsp::Diagnostic { - severity: Some(lsp::DiagnosticSeverity::ERROR), - range: lsp::Range::new(lsp::Position::new(0, 4), lsp::Position::new(0, 7)), - message: "message 1".to_string(), - ..Default::default() - }], - }, - ); - - // Wait for server to see the diagnostics update. - deterministic.run_until_parked(); - { - let store = server.store.read().await; - let project = store.project(project_id).unwrap(); - let worktree = project.worktrees.get(&worktree_id.to_proto()).unwrap(); - assert!(!worktree.diagnostic_summaries.is_empty()); - } - - // Ensure client B observes the new diagnostics. - project_b.read_with(cx_b, |project, cx| { - assert_eq!( - project.diagnostic_summaries(cx).collect::>(), - &[( - ProjectPath { - worktree_id, - path: Arc::from(Path::new("a.rs")), - }, - DiagnosticSummary { - error_count: 1, - warning_count: 0, - ..Default::default() - }, - )] - ) - }); - - // Join project as client C and observe the diagnostics. - let project_c = client_c.build_remote_project(&project_a, cx_a, cx_c).await; - project_c.read_with(cx_c, |project, cx| { - assert_eq!( - project.diagnostic_summaries(cx).collect::>(), - &[( - ProjectPath { - worktree_id, - path: Arc::from(Path::new("a.rs")), - }, - DiagnosticSummary { - error_count: 1, - warning_count: 0, - ..Default::default() - }, - )] - ) - }); - - // Simulate a language server reporting more errors for a file. - fake_language_server.notify::( - lsp::PublishDiagnosticsParams { - uri: lsp::Url::from_file_path("/a/a.rs").unwrap(), - version: None, - diagnostics: vec![ - lsp::Diagnostic { - severity: Some(lsp::DiagnosticSeverity::ERROR), - range: lsp::Range::new(lsp::Position::new(0, 4), lsp::Position::new(0, 7)), - message: "message 1".to_string(), - ..Default::default() - }, - lsp::Diagnostic { - severity: Some(lsp::DiagnosticSeverity::WARNING), - range: lsp::Range::new( - lsp::Position::new(0, 10), - lsp::Position::new(0, 13), - ), - message: "message 2".to_string(), - ..Default::default() - }, - ], - }, - ); - - // Clients B and C get the updated summaries - deterministic.run_until_parked(); - project_b.read_with(cx_b, |project, cx| { - assert_eq!( - project.diagnostic_summaries(cx).collect::>(), - [( - ProjectPath { - worktree_id, - path: Arc::from(Path::new("a.rs")), - }, - DiagnosticSummary { - error_count: 1, - warning_count: 1, - ..Default::default() - }, - )] - ); - }); - project_c.read_with(cx_c, |project, cx| { - assert_eq!( - project.diagnostic_summaries(cx).collect::>(), - [( - ProjectPath { - worktree_id, - path: Arc::from(Path::new("a.rs")), - }, - DiagnosticSummary { - error_count: 1, - warning_count: 1, - ..Default::default() - }, - )] - ); - }); - - // Open the file with the errors on client B. They should be present. - let buffer_b = cx_b - .background() - .spawn(project_b.update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.rs"), cx))) - .await - .unwrap(); - - buffer_b.read_with(cx_b, |buffer, _| { - assert_eq!( - buffer - .snapshot() - .diagnostics_in_range::<_, Point>(0..buffer.len(), false) - .map(|entry| entry) - .collect::>(), - &[ - DiagnosticEntry { - range: Point::new(0, 4)..Point::new(0, 7), - diagnostic: Diagnostic { - group_id: 0, - message: "message 1".to_string(), - severity: lsp::DiagnosticSeverity::ERROR, - is_primary: true, - ..Default::default() - } - }, - DiagnosticEntry { - range: Point::new(0, 10)..Point::new(0, 13), - diagnostic: Diagnostic { - group_id: 1, - severity: lsp::DiagnosticSeverity::WARNING, - message: "message 2".to_string(), - is_primary: true, - ..Default::default() - } - } - ] - ); - }); - - // Simulate a language server reporting no errors for a file. - fake_language_server.notify::( - lsp::PublishDiagnosticsParams { - uri: lsp::Url::from_file_path("/a/a.rs").unwrap(), - version: None, - diagnostics: vec![], - }, - ); - deterministic.run_until_parked(); - project_a.read_with(cx_a, |project, cx| { - assert_eq!(project.diagnostic_summaries(cx).collect::>(), []) - }); - project_b.read_with(cx_b, |project, cx| { - assert_eq!(project.diagnostic_summaries(cx).collect::>(), []) - }); - project_c.read_with(cx_c, |project, cx| { - assert_eq!(project.diagnostic_summaries(cx).collect::>(), []) - }); - } - - #[gpui::test(iterations = 10)] - async fn test_collaborating_with_completion( - cx_a: &mut TestAppContext, - cx_b: &mut TestAppContext, - ) { - cx_a.foreground().forbid_parking(); - let lang_registry = Arc::new(LanguageRegistry::test()); - let fs = FakeFs::new(cx_a.background()); - - // Set up a fake language server. - let mut language = Language::new( - LanguageConfig { - name: "Rust".into(), - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - Some(tree_sitter_rust::language()), - ); - let mut fake_language_servers = language.set_fake_lsp_adapter(FakeLspAdapter { - capabilities: lsp::ServerCapabilities { - completion_provider: Some(lsp::CompletionOptions { - trigger_characters: Some(vec![".".to_string()]), - ..Default::default() - }), - ..Default::default() - }, - ..Default::default() - }); - lang_registry.add(Arc::new(language)); - - // Connect to a server as 2 clients. - let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; - let client_a = server.create_client(cx_a, "user_a").await; - let mut client_b = server.create_client(cx_b, "user_b").await; - server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) - .await; - - // Share a project as client A - fs.insert_tree( - "/a", - json!({ - "main.rs": "fn main() { a }", - "other.rs": "", - }), - ) - .await; - let project_a = cx_a.update(|cx| { - Project::local( - client_a.clone(), - client_a.user_store.clone(), - lang_registry.clone(), - fs.clone(), - cx, - ) - }); - let (worktree_a, _) = project_a - .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/a", true, cx) - }) - .await - .unwrap(); - worktree_a - .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete()) - .await; - let worktree_id = worktree_a.read_with(cx_a, |tree, _| tree.id()); - - // Join the worktree as client B. - let project_b = client_b.build_remote_project(&project_a, cx_a, 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 (window_b, _) = cx_b.add_window(|_| EmptyView); - let editor_b = cx_b.add_view(window_b, |cx| { - Editor::for_buffer(buffer_b.clone(), Some(project_b.clone()), cx) - }); - - let fake_language_server = fake_language_servers.next().await.unwrap(); - buffer_b - .condition(&cx_b, |buffer, _| !buffer.completion_triggers().is_empty()) - .await; - - // 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(&Input(".".into()), cx); - cx.focus(&editor_b); - }); - - // Receive a completion request as the host's language server. - // Return some completions from the host's language server. - cx_a.foreground().start_waiting(); - fake_language_server - .handle_request::(|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".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.foreground().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(); - buffer_a - .condition(&cx_a, |buffer, _| buffer.text() == "fn main() { a. }") - .await; - - // Confirm a completion on the guest. - editor_b - .condition(&cx_b, |editor, _| editor.context_menu_visible()) - .await; - editor_b.update(cx_b, |editor, cx| { - 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::( - |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. - buffer_a - .condition(&cx_a, |buffer, _| { - buffer.text() == "use d::SomeTrait;\nfn main() { a.first_method() }" - }) - .await; - buffer_b - .condition(&cx_b, |buffer, _| { - buffer.text() == "use d::SomeTrait;\nfn main() { a.first_method() }" - }) - .await; - } - - #[gpui::test(iterations = 10)] - async fn test_reloading_buffer_manually(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { - cx_a.foreground().forbid_parking(); - let lang_registry = Arc::new(LanguageRegistry::test()); - let fs = FakeFs::new(cx_a.background()); - - // Connect to a server as 2 clients. - let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; - let client_a = server.create_client(cx_a, "user_a").await; - let mut client_b = server.create_client(cx_b, "user_b").await; - server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) - .await; - - // Share a project as client A - fs.insert_tree( - "/a", - json!({ - "a.rs": "let one = 1;", - }), - ) - .await; - let project_a = cx_a.update(|cx| { - Project::local( - client_a.clone(), - client_a.user_store.clone(), - lang_registry.clone(), - fs.clone(), - cx, - ) - }); - let (worktree_a, _) = project_a - .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/a", true, cx) - }) - .await - .unwrap(); - worktree_a - .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete()) - .await; - let worktree_id = worktree_a.read_with(cx_a, |tree, _| tree.id()); - let buffer_a = project_a - .update(cx_a, |p, cx| p.open_buffer((worktree_id, "a.rs"), cx)) - .await - .unwrap(); - - // Join the worktree as client B. - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; - - let buffer_b = cx_b - .background() - .spawn(project_b.update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.rs"), cx))) - .await - .unwrap(); - buffer_b.update(cx_b, |buffer, cx| { - buffer.edit([(4..7, "six")], cx); - buffer.edit([(10..11, "6")], cx); - assert_eq!(buffer.text(), "let six = 6;"); - assert!(buffer.is_dirty()); - assert!(!buffer.has_conflict()); - }); - buffer_a - .condition(cx_a, |buffer, _| buffer.text() == "let six = 6;") - .await; - - fs.save(Path::new("/a/a.rs"), &Rope::from("let seven = 7;")) - .await - .unwrap(); - buffer_a - .condition(cx_a, |buffer, _| buffer.has_conflict()) - .await; - buffer_b - .condition(cx_b, |buffer, _| buffer.has_conflict()) - .await; - - project_b - .update(cx_b, |project, cx| { - project.reload_buffers(HashSet::from_iter([buffer_b.clone()]), true, cx) - }) - .await - .unwrap(); - buffer_a.read_with(cx_a, |buffer, _| { - assert_eq!(buffer.text(), "let seven = 7;"); - assert!(!buffer.is_dirty()); - assert!(!buffer.has_conflict()); - }); - buffer_b.read_with(cx_b, |buffer, _| { - assert_eq!(buffer.text(), "let seven = 7;"); - assert!(!buffer.is_dirty()); - assert!(!buffer.has_conflict()); - }); - - buffer_a.update(cx_a, |buffer, cx| { - // Undoing on the host is a no-op when the reload was initiated by the guest. - buffer.undo(cx); - assert_eq!(buffer.text(), "let seven = 7;"); - assert!(!buffer.is_dirty()); - assert!(!buffer.has_conflict()); - }); - buffer_b.update(cx_b, |buffer, cx| { - // Undoing on the guest rolls back the buffer to before it was reloaded but the conflict gets cleared. - buffer.undo(cx); - assert_eq!(buffer.text(), "let six = 6;"); - assert!(buffer.is_dirty()); - assert!(!buffer.has_conflict()); - }); - } - - #[gpui::test(iterations = 10)] - async fn test_formatting_buffer(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { - cx_a.foreground().forbid_parking(); - let lang_registry = Arc::new(LanguageRegistry::test()); - let fs = FakeFs::new(cx_a.background()); - - // Set up a fake language server. - let mut language = Language::new( - LanguageConfig { - name: "Rust".into(), - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - Some(tree_sitter_rust::language()), - ); - let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()); - lang_registry.add(Arc::new(language)); - - // Connect to a server as 2 clients. - let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; - let client_a = server.create_client(cx_a, "user_a").await; - let mut client_b = server.create_client(cx_b, "user_b").await; - server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) - .await; - - // Share a project as client A - fs.insert_tree( - "/a", - json!({ - "a.rs": "let one = two", - }), - ) - .await; - let project_a = cx_a.update(|cx| { - Project::local( - client_a.clone(), - client_a.user_store.clone(), - lang_registry.clone(), - fs.clone(), - cx, - ) - }); - let (worktree_a, _) = project_a - .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/a", true, cx) - }) - .await - .unwrap(); - worktree_a - .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete()) - .await; - let worktree_id = worktree_a.read_with(cx_a, |tree, _| tree.id()); - - // Join the project as client B. - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; - - let buffer_b = cx_b - .background() - .spawn(project_b.update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.rs"), cx))) - .await - .unwrap(); - - let fake_language_server = fake_language_servers.next().await.unwrap(); - fake_language_server.handle_request::(|_, _| async move { - Ok(Some(vec![ - lsp::TextEdit { - range: lsp::Range::new(lsp::Position::new(0, 4), lsp::Position::new(0, 4)), - new_text: "h".to_string(), - }, - lsp::TextEdit { - range: lsp::Range::new(lsp::Position::new(0, 7), lsp::Position::new(0, 7)), - new_text: "y".to_string(), - }, - ])) - }); - - project_b - .update(cx_b, |project, cx| { - project.format(HashSet::from_iter([buffer_b.clone()]), true, cx) - }) - .await - .unwrap(); - assert_eq!( - buffer_b.read_with(cx_b, |buffer, _| buffer.text()), - "let honey = two" - ); - } - - #[gpui::test(iterations = 10)] - async fn test_definition(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { - cx_a.foreground().forbid_parking(); - let lang_registry = Arc::new(LanguageRegistry::test()); - let fs = FakeFs::new(cx_a.background()); - fs.insert_tree( - "/root-1", - json!({ - "a.rs": "const ONE: usize = b::TWO + b::THREE;", - }), - ) - .await; - fs.insert_tree( - "/root-2", - json!({ - "b.rs": "const TWO: usize = 2;\nconst THREE: usize = 3;", - }), - ) - .await; - - // Set up a fake language server. - let mut language = Language::new( - LanguageConfig { - name: "Rust".into(), - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - Some(tree_sitter_rust::language()), - ); - let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()); - lang_registry.add(Arc::new(language)); - - // Connect to a server as 2 clients. - let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; - let client_a = server.create_client(cx_a, "user_a").await; - let mut client_b = server.create_client(cx_b, "user_b").await; - server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) - .await; - - // Share a project as client A - let project_a = cx_a.update(|cx| { - Project::local( - client_a.clone(), - client_a.user_store.clone(), - lang_registry.clone(), - fs.clone(), - cx, - ) - }); - let (worktree_a, _) = project_a - .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/root-1", true, cx) - }) - .await - .unwrap(); - worktree_a - .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete()) - .await; - let worktree_id = worktree_a.read_with(cx_a, |tree, _| tree.id()); - - // Join the project as client B. - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; - - // Open the file on client B. - let buffer_b = cx_b - .background() - .spawn(project_b.update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.rs"), cx))) - .await - .unwrap(); - - // Request the definition of a symbol as the guest. - let fake_language_server = fake_language_servers.next().await.unwrap(); - fake_language_server.handle_request::( - |_, _| async move { - Ok(Some(lsp::GotoDefinitionResponse::Scalar( - lsp::Location::new( - lsp::Url::from_file_path("/root-2/b.rs").unwrap(), - lsp::Range::new(lsp::Position::new(0, 6), lsp::Position::new(0, 9)), - ), - ))) - }, - ); - - let definitions_1 = project_b - .update(cx_b, |p, cx| p.definition(&buffer_b, 23, cx)) - .await - .unwrap(); - cx_b.read(|cx| { - assert_eq!(definitions_1.len(), 1); - assert_eq!(project_b.read(cx).worktrees(cx).count(), 2); - let target_buffer = definitions_1[0].buffer.read(cx); - assert_eq!( - target_buffer.text(), - "const TWO: usize = 2;\nconst THREE: usize = 3;" - ); - assert_eq!( - definitions_1[0].range.to_point(target_buffer), - Point::new(0, 6)..Point::new(0, 9) - ); - }); - - // Try getting more definitions for the same buffer, ensuring the buffer gets reused from - // the previous call to `definition`. - fake_language_server.handle_request::( - |_, _| async move { - Ok(Some(lsp::GotoDefinitionResponse::Scalar( - lsp::Location::new( - lsp::Url::from_file_path("/root-2/b.rs").unwrap(), - lsp::Range::new(lsp::Position::new(1, 6), lsp::Position::new(1, 11)), - ), - ))) - }, - ); - - let definitions_2 = project_b - .update(cx_b, |p, cx| p.definition(&buffer_b, 33, cx)) - .await - .unwrap(); - cx_b.read(|cx| { - assert_eq!(definitions_2.len(), 1); - assert_eq!(project_b.read(cx).worktrees(cx).count(), 2); - let target_buffer = definitions_2[0].buffer.read(cx); - assert_eq!( - target_buffer.text(), - "const TWO: usize = 2;\nconst THREE: usize = 3;" - ); - assert_eq!( - definitions_2[0].range.to_point(target_buffer), - Point::new(1, 6)..Point::new(1, 11) - ); - }); - assert_eq!(definitions_1[0].buffer, definitions_2[0].buffer); - } - - #[gpui::test(iterations = 10)] - async fn test_references(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { - cx_a.foreground().forbid_parking(); - let lang_registry = Arc::new(LanguageRegistry::test()); - let fs = FakeFs::new(cx_a.background()); - fs.insert_tree( - "/root-1", - json!({ - "one.rs": "const ONE: usize = 1;", - "two.rs": "const TWO: usize = one::ONE + one::ONE;", - }), - ) - .await; - fs.insert_tree( - "/root-2", - json!({ - "three.rs": "const THREE: usize = two::TWO + one::ONE;", - }), - ) - .await; - - // Set up a fake language server. - let mut language = Language::new( - LanguageConfig { - name: "Rust".into(), - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - Some(tree_sitter_rust::language()), - ); - let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()); - lang_registry.add(Arc::new(language)); - - // Connect to a server as 2 clients. - let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; - let client_a = server.create_client(cx_a, "user_a").await; - let mut client_b = server.create_client(cx_b, "user_b").await; - server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) - .await; - - // Share a project as client A - let project_a = cx_a.update(|cx| { - Project::local( - client_a.clone(), - client_a.user_store.clone(), - lang_registry.clone(), - fs.clone(), - cx, - ) - }); - let (worktree_a, _) = project_a - .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/root-1", true, cx) - }) - .await - .unwrap(); - worktree_a - .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete()) - .await; - let worktree_id = worktree_a.read_with(cx_a, |tree, _| tree.id()); - - // Join the worktree as client B. - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; - - // Open the file on client B. - let buffer_b = cx_b - .background() - .spawn(project_b.update(cx_b, |p, cx| p.open_buffer((worktree_id, "one.rs"), cx))) - .await - .unwrap(); - - // Request references to a symbol as the guest. - let fake_language_server = fake_language_servers.next().await.unwrap(); - fake_language_server.handle_request::( - |params, _| async move { - assert_eq!( - params.text_document_position.text_document.uri.as_str(), - "file:///root-1/one.rs" - ); - Ok(Some(vec![ - lsp::Location { - uri: lsp::Url::from_file_path("/root-1/two.rs").unwrap(), - range: lsp::Range::new( - lsp::Position::new(0, 24), - lsp::Position::new(0, 27), - ), - }, - lsp::Location { - uri: lsp::Url::from_file_path("/root-1/two.rs").unwrap(), - range: lsp::Range::new( - lsp::Position::new(0, 35), - lsp::Position::new(0, 38), - ), - }, - lsp::Location { - uri: lsp::Url::from_file_path("/root-2/three.rs").unwrap(), - range: lsp::Range::new( - lsp::Position::new(0, 37), - lsp::Position::new(0, 40), - ), - }, - ])) - }, - ); - - let references = project_b - .update(cx_b, |p, cx| p.references(&buffer_b, 7, cx)) - .await - .unwrap(); - cx_b.read(|cx| { - assert_eq!(references.len(), 3); - assert_eq!(project_b.read(cx).worktrees(cx).count(), 2); - - let two_buffer = references[0].buffer.read(cx); - let three_buffer = references[2].buffer.read(cx); - assert_eq!( - two_buffer.file().unwrap().path().as_ref(), - Path::new("two.rs") - ); - assert_eq!(references[1].buffer, references[0].buffer); - assert_eq!( - three_buffer.file().unwrap().full_path(cx), - Path::new("three.rs") - ); - - assert_eq!(references[0].range.to_offset(&two_buffer), 24..27); - assert_eq!(references[1].range.to_offset(&two_buffer), 35..38); - assert_eq!(references[2].range.to_offset(&three_buffer), 37..40); - }); - } - - #[gpui::test(iterations = 10)] - async fn test_project_search(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { - cx_a.foreground().forbid_parking(); - let lang_registry = Arc::new(LanguageRegistry::test()); - let fs = FakeFs::new(cx_a.background()); - fs.insert_tree( - "/root-1", - json!({ - "a": "hello world", - "b": "goodnight moon", - "c": "a world of goo", - "d": "world champion of clown world", - }), - ) - .await; - fs.insert_tree( - "/root-2", - json!({ - "e": "disney world is fun", - }), - ) - .await; - - // Connect to a server as 2 clients. - let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; - let client_a = server.create_client(cx_a, "user_a").await; - let mut client_b = server.create_client(cx_b, "user_b").await; - server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) - .await; - - // Share a project as client A - let project_a = cx_a.update(|cx| { - Project::local( - client_a.clone(), - client_a.user_store.clone(), - lang_registry.clone(), - fs.clone(), - cx, - ) - }); - - let (worktree_1, _) = project_a - .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/root-1", true, cx) - }) - .await - .unwrap(); - worktree_1 - .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete()) - .await; - let (worktree_2, _) = project_a - .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/root-2", true, cx) - }) - .await - .unwrap(); - worktree_2 - .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete()) - .await; - - // Join the worktree as client B. - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; - let results = project_b - .update(cx_b, |project, cx| { - project.search(SearchQuery::text("world", false, false), cx) - }) - .await - .unwrap(); - - let mut ranges_by_path = results - .into_iter() - .map(|(buffer, ranges)| { - buffer.read_with(cx_b, |buffer, cx| { - let path = buffer.file().unwrap().full_path(cx); - let offset_ranges = ranges - .into_iter() - .map(|range| range.to_offset(buffer)) - .collect::>(); - (path, offset_ranges) - }) - }) - .collect::>(); - ranges_by_path.sort_by_key(|(path, _)| path.clone()); - - assert_eq!( - ranges_by_path, - &[ - (PathBuf::from("root-1/a"), vec![6..11]), - (PathBuf::from("root-1/c"), vec![2..7]), - (PathBuf::from("root-1/d"), vec![0..5, 24..29]), - (PathBuf::from("root-2/e"), vec![7..12]), - ] - ); - } - - #[gpui::test(iterations = 10)] - async fn test_document_highlights(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { - cx_a.foreground().forbid_parking(); - let lang_registry = Arc::new(LanguageRegistry::test()); - let fs = FakeFs::new(cx_a.background()); - fs.insert_tree( - "/root-1", - json!({ - "main.rs": "fn double(number: i32) -> i32 { number + number }", - }), - ) - .await; - - // Set up a fake language server. - let mut language = Language::new( - LanguageConfig { - name: "Rust".into(), - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - Some(tree_sitter_rust::language()), - ); - let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()); - lang_registry.add(Arc::new(language)); - - // Connect to a server as 2 clients. - let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; - let client_a = server.create_client(cx_a, "user_a").await; - let mut client_b = server.create_client(cx_b, "user_b").await; - server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) - .await; - - // Share a project as client A - let project_a = cx_a.update(|cx| { - Project::local( - client_a.clone(), - client_a.user_store.clone(), - lang_registry.clone(), - fs.clone(), - cx, - ) - }); - let (worktree_a, _) = project_a - .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/root-1", true, cx) - }) - .await - .unwrap(); - worktree_a - .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete()) - .await; - let worktree_id = worktree_a.read_with(cx_a, |tree, _| tree.id()); - - // Join the worktree as client B. - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; - - // Open the file on client B. - let buffer_b = cx_b - .background() - .spawn(project_b.update(cx_b, |p, cx| p.open_buffer((worktree_id, "main.rs"), cx))) - .await - .unwrap(); - - // Request document highlights as the guest. - let fake_language_server = fake_language_servers.next().await.unwrap(); - fake_language_server.handle_request::( - |params, _| async move { - assert_eq!( - params - .text_document_position_params - .text_document - .uri - .as_str(), - "file:///root-1/main.rs" - ); - assert_eq!( - params.text_document_position_params.position, - lsp::Position::new(0, 34) - ); - Ok(Some(vec![ - lsp::DocumentHighlight { - kind: Some(lsp::DocumentHighlightKind::WRITE), - range: lsp::Range::new( - lsp::Position::new(0, 10), - lsp::Position::new(0, 16), - ), - }, - lsp::DocumentHighlight { - kind: Some(lsp::DocumentHighlightKind::READ), - range: lsp::Range::new( - lsp::Position::new(0, 32), - lsp::Position::new(0, 38), - ), - }, - lsp::DocumentHighlight { - kind: Some(lsp::DocumentHighlightKind::READ), - range: lsp::Range::new( - lsp::Position::new(0, 41), - lsp::Position::new(0, 47), - ), - }, - ])) - }, - ); - - let highlights = project_b - .update(cx_b, |p, cx| p.document_highlights(&buffer_b, 34, cx)) - .await - .unwrap(); - buffer_b.read_with(cx_b, |buffer, _| { - let snapshot = buffer.snapshot(); - - let highlights = highlights - .into_iter() - .map(|highlight| (highlight.kind, highlight.range.to_offset(&snapshot))) - .collect::>(); - assert_eq!( - highlights, - &[ - (lsp::DocumentHighlightKind::WRITE, 10..16), - (lsp::DocumentHighlightKind::READ, 32..38), - (lsp::DocumentHighlightKind::READ, 41..47) - ] - ) - }); - } - - #[gpui::test(iterations = 10)] - async fn test_project_symbols(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { - cx_a.foreground().forbid_parking(); - let lang_registry = Arc::new(LanguageRegistry::test()); - let fs = FakeFs::new(cx_a.background()); - fs.insert_tree( - "/code", - json!({ - "crate-1": { - "one.rs": "const ONE: usize = 1;", - }, - "crate-2": { - "two.rs": "const TWO: usize = 2; const THREE: usize = 3;", - }, - "private": { - "passwords.txt": "the-password", - } - }), - ) - .await; - - // Set up a fake language server. - let mut language = Language::new( - LanguageConfig { - name: "Rust".into(), - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - Some(tree_sitter_rust::language()), - ); - let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()); - lang_registry.add(Arc::new(language)); - - // Connect to a server as 2 clients. - let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; - let client_a = server.create_client(cx_a, "user_a").await; - let mut client_b = server.create_client(cx_b, "user_b").await; - server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) - .await; - - // Share a project as client A - let project_a = cx_a.update(|cx| { - Project::local( - client_a.clone(), - client_a.user_store.clone(), - lang_registry.clone(), - fs.clone(), - cx, - ) - }); - let (worktree_a, _) = project_a - .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/code/crate-1", true, cx) - }) - .await - .unwrap(); - worktree_a - .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete()) - .await; - let worktree_id = worktree_a.read_with(cx_a, |tree, _| tree.id()); - - // Join the worktree as client B. - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; - - // Cause the language server to start. - let _buffer = cx_b - .background() - .spawn(project_b.update(cx_b, |p, cx| p.open_buffer((worktree_id, "one.rs"), cx))) - .await - .unwrap(); - - let fake_language_server = fake_language_servers.next().await.unwrap(); - fake_language_server.handle_request::( - |_, _| async move { - #[allow(deprecated)] - Ok(Some(vec![lsp::SymbolInformation { - name: "TWO".into(), - location: lsp::Location { - uri: lsp::Url::from_file_path("/code/crate-2/two.rs").unwrap(), - range: lsp::Range::new(lsp::Position::new(0, 6), lsp::Position::new(0, 9)), - }, - kind: lsp::SymbolKind::CONSTANT, - tags: None, - container_name: None, - deprecated: None, - }])) - }, - ); - - // Request the definition of a symbol as the guest. - let symbols = project_b - .update(cx_b, |p, cx| p.symbols("two", cx)) - .await - .unwrap(); - assert_eq!(symbols.len(), 1); - assert_eq!(symbols[0].name, "TWO"); - - // Open one of the returned symbols. - let buffer_b_2 = project_b - .update(cx_b, |project, cx| { - project.open_buffer_for_symbol(&symbols[0], cx) - }) - .await - .unwrap(); - buffer_b_2.read_with(cx_b, |buffer, _| { - assert_eq!( - buffer.file().unwrap().path().as_ref(), - Path::new("../crate-2/two.rs") - ); - }); - - // Attempt to craft a symbol and violate host's privacy by opening an arbitrary file. - let mut fake_symbol = symbols[0].clone(); - fake_symbol.path = Path::new("/code/secrets").into(); - let error = project_b - .update(cx_b, |project, cx| { - project.open_buffer_for_symbol(&fake_symbol, cx) - }) - .await - .unwrap_err(); - assert!(error.to_string().contains("invalid symbol signature")); - } - - #[gpui::test(iterations = 10)] - async fn test_open_buffer_while_getting_definition_pointing_to_it( - cx_a: &mut TestAppContext, - cx_b: &mut TestAppContext, - mut rng: StdRng, - ) { - cx_a.foreground().forbid_parking(); - let lang_registry = Arc::new(LanguageRegistry::test()); - let fs = FakeFs::new(cx_a.background()); - fs.insert_tree( - "/root", - json!({ - "a.rs": "const ONE: usize = b::TWO;", - "b.rs": "const TWO: usize = 2", - }), - ) - .await; - - // Set up a fake language server. - let mut language = Language::new( - LanguageConfig { - name: "Rust".into(), - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - Some(tree_sitter_rust::language()), - ); - let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()); - lang_registry.add(Arc::new(language)); - - // Connect to a server as 2 clients. - let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; - let client_a = server.create_client(cx_a, "user_a").await; - let mut client_b = server.create_client(cx_b, "user_b").await; - server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) - .await; - - // Share a project as client A - let project_a = cx_a.update(|cx| { - Project::local( - client_a.clone(), - client_a.user_store.clone(), - lang_registry.clone(), - fs.clone(), - cx, - ) - }); - - let (worktree_a, _) = project_a - .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/root", true, cx) - }) - .await - .unwrap(); - worktree_a - .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete()) - .await; - let worktree_id = worktree_a.read_with(cx_a, |tree, _| tree.id()); - - // Join the project as client B. - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; - - let buffer_b1 = cx_b - .background() - .spawn(project_b.update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.rs"), cx))) - .await - .unwrap(); - - let fake_language_server = fake_language_servers.next().await.unwrap(); - fake_language_server.handle_request::( - |_, _| async move { - Ok(Some(lsp::GotoDefinitionResponse::Scalar( - lsp::Location::new( - lsp::Url::from_file_path("/root/b.rs").unwrap(), - lsp::Range::new(lsp::Position::new(0, 6), lsp::Position::new(0, 9)), - ), - ))) - }, - ); - - let definitions; - let buffer_b2; - if rng.gen() { - definitions = project_b.update(cx_b, |p, cx| p.definition(&buffer_b1, 23, cx)); - buffer_b2 = project_b.update(cx_b, |p, cx| p.open_buffer((worktree_id, "b.rs"), cx)); - } else { - buffer_b2 = project_b.update(cx_b, |p, cx| p.open_buffer((worktree_id, "b.rs"), cx)); - definitions = project_b.update(cx_b, |p, cx| p.definition(&buffer_b1, 23, cx)); - } - - let buffer_b2 = buffer_b2.await.unwrap(); - let definitions = definitions.await.unwrap(); - assert_eq!(definitions.len(), 1); - assert_eq!(definitions[0].buffer, buffer_b2); - } - - #[gpui::test(iterations = 10)] - async fn test_collaborating_with_code_actions( - cx_a: &mut TestAppContext, - cx_b: &mut TestAppContext, - ) { - cx_a.foreground().forbid_parking(); - let lang_registry = Arc::new(LanguageRegistry::test()); - let fs = FakeFs::new(cx_a.background()); - cx_b.update(|cx| editor::init(cx)); - - // Set up a fake language server. - let mut language = Language::new( - LanguageConfig { - name: "Rust".into(), - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - Some(tree_sitter_rust::language()), - ); - let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()); - lang_registry.add(Arc::new(language)); - - // Connect to a server as 2 clients. - let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; - let client_a = server.create_client(cx_a, "user_a").await; - let mut client_b = server.create_client(cx_b, "user_b").await; - server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) - .await; - - // Share a project as 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 = cx_a.update(|cx| { - Project::local( - client_a.clone(), - client_a.user_store.clone(), - lang_registry.clone(), - fs.clone(), - cx, - ) - }); - let (worktree_a, _) = project_a - .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/a", true, cx) - }) - .await - .unwrap(); - worktree_a - .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete()) - .await; - let worktree_id = worktree_a.read_with(cx_a, |tree, _| tree.id()); - - // Join the project as client B. - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; - let (_window_b, workspace_b) = cx_b.add_window(|cx| Workspace::new(project_b.clone(), cx)); - let editor_b = workspace_b - .update(cx_b, |workspace, cx| { - workspace.open_path((worktree_id, "main.rs"), true, cx) - }) - .await - .unwrap() - .downcast::() - .unwrap(); - - let mut fake_language_server = fake_language_servers.next().await.unwrap(); - fake_language_server - .handle_request::(|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) - }) - .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.focus(&editor_b); - }); - - fake_language_server - .handle_request::(|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() - }, - )])) - }) - .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, - ); - }); - editor_b - .condition(&cx_b, |editor, _| editor.context_menu_visible()) - .await; - - fake_language_server.remove_request_handler::(); - - // Confirming the code action will trigger a resolve request. - let confirm_action = workspace_b - .update(cx_b, |workspace, cx| { - Editor::confirm_code_action(workspace, &ConfirmCodeAction { item_ix: Some(0) }, cx) - }) - .unwrap(); - fake_language_server.handle_request::( - |_, _| 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.read_with(cx_b, |workspace, cx| { - workspace - .active_item(cx) - .unwrap() - .downcast::() - .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) { - cx_a.foreground().forbid_parking(); - let lang_registry = Arc::new(LanguageRegistry::test()); - let fs = FakeFs::new(cx_a.background()); - cx_b.update(|cx| editor::init(cx)); - - // Set up a fake language server. - let mut language = Language::new( - LanguageConfig { - name: "Rust".into(), - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - Some(tree_sitter_rust::language()), - ); - let mut fake_language_servers = language.set_fake_lsp_adapter(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() - }); - lang_registry.add(Arc::new(language)); - - // Connect to a server as 2 clients. - let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; - let client_a = server.create_client(cx_a, "user_a").await; - let mut client_b = server.create_client(cx_b, "user_b").await; - server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) - .await; - - // Share a project as 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 = cx_a.update(|cx| { - Project::local( - client_a.clone(), - client_a.user_store.clone(), - lang_registry.clone(), - fs.clone(), - cx, - ) - }); - let (worktree_a, _) = project_a - .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/dir", true, cx) - }) - .await - .unwrap(); - worktree_a - .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete()) - .await; - let worktree_id = worktree_a.read_with(cx_a, |tree, _| tree.id()); - - // Join the worktree as client B. - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; - let (_window_b, workspace_b) = cx_b.add_window(|cx| Workspace::new(project_b.clone(), cx)); - let editor_b = workspace_b - .update(cx_b, |workspace, cx| { - workspace.open_path((worktree_id, "one.rs"), true, cx) - }) - .await - .unwrap() - .downcast::() - .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::(|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| { - 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| { - rename_editor.buffer().update(cx, |rename_buffer, cx| { - rename_buffer.edit([(0..3, "THREE")], cx); - }); - }); - }); - - let confirm_rename = workspace_b.update(cx_b, |workspace, cx| { - Editor::confirm_rename(workspace, &ConfirmRename, cx).unwrap() - }); - fake_language_server - .handle_request::(|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.read_with(cx_b, |workspace, cx| { - workspace - .active_item(cx) - .unwrap() - .downcast::() - .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_basic_chat(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { - cx_a.foreground().forbid_parking(); - - // Connect to a server as 2 clients. - let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; - let client_a = server.create_client(cx_a, "user_a").await; - let client_b = server.create_client(cx_b, "user_b").await; - - // Create an org that includes these 2 users. - let db = &server.app_state.db; - let org_id = db.create_org("Test Org", "test-org").await.unwrap(); - db.add_org_member(org_id, client_a.current_user_id(&cx_a), false) - .await - .unwrap(); - db.add_org_member(org_id, client_b.current_user_id(&cx_b), false) - .await - .unwrap(); - - // Create a channel that includes all the users. - let channel_id = db.create_org_channel(org_id, "test-channel").await.unwrap(); - db.add_channel_member(channel_id, client_a.current_user_id(&cx_a), false) - .await - .unwrap(); - db.add_channel_member(channel_id, client_b.current_user_id(&cx_b), false) - .await - .unwrap(); - db.create_channel_message( - channel_id, - client_b.current_user_id(&cx_b), - "hello A, it's B.", - OffsetDateTime::now_utc(), - 1, - ) - .await - .unwrap(); - - let channels_a = cx_a - .add_model(|cx| ChannelList::new(client_a.user_store.clone(), client_a.clone(), cx)); - channels_a - .condition(cx_a, |list, _| list.available_channels().is_some()) - .await; - channels_a.read_with(cx_a, |list, _| { - assert_eq!( - list.available_channels().unwrap(), - &[ChannelDetails { - id: channel_id.to_proto(), - name: "test-channel".to_string() - }] - ) - }); - let channel_a = channels_a.update(cx_a, |this, cx| { - this.get_channel(channel_id.to_proto(), cx).unwrap() - }); - channel_a.read_with(cx_a, |channel, _| assert!(channel.messages().is_empty())); - channel_a - .condition(&cx_a, |channel, _| { - channel_messages(channel) - == [("user_b".to_string(), "hello A, it's B.".to_string(), false)] - }) - .await; - - let channels_b = cx_b - .add_model(|cx| ChannelList::new(client_b.user_store.clone(), client_b.clone(), cx)); - channels_b - .condition(cx_b, |list, _| list.available_channels().is_some()) - .await; - channels_b.read_with(cx_b, |list, _| { - assert_eq!( - list.available_channels().unwrap(), - &[ChannelDetails { - id: channel_id.to_proto(), - name: "test-channel".to_string() - }] - ) - }); - - let channel_b = channels_b.update(cx_b, |this, cx| { - this.get_channel(channel_id.to_proto(), cx).unwrap() - }); - channel_b.read_with(cx_b, |channel, _| assert!(channel.messages().is_empty())); - channel_b - .condition(&cx_b, |channel, _| { - channel_messages(channel) - == [("user_b".to_string(), "hello A, it's B.".to_string(), false)] - }) - .await; - - channel_a - .update(cx_a, |channel, cx| { - channel - .send_message("oh, hi B.".to_string(), cx) - .unwrap() - .detach(); - let task = channel.send_message("sup".to_string(), cx).unwrap(); - assert_eq!( - channel_messages(channel), - &[ - ("user_b".to_string(), "hello A, it's B.".to_string(), false), - ("user_a".to_string(), "oh, hi B.".to_string(), true), - ("user_a".to_string(), "sup".to_string(), true) - ] - ); - task - }) - .await - .unwrap(); - - channel_b - .condition(&cx_b, |channel, _| { - channel_messages(channel) - == [ - ("user_b".to_string(), "hello A, it's B.".to_string(), false), - ("user_a".to_string(), "oh, hi B.".to_string(), false), - ("user_a".to_string(), "sup".to_string(), false), - ] - }) - .await; - - assert_eq!( - server - .state() - .await - .channel(channel_id) - .unwrap() - .connection_ids - .len(), - 2 - ); - cx_b.update(|_| drop(channel_b)); - server - .condition(|state| state.channel(channel_id).unwrap().connection_ids.len() == 1) - .await; - - cx_a.update(|_| drop(channel_a)); - server - .condition(|state| state.channel(channel_id).is_none()) - .await; - } - - #[gpui::test(iterations = 10)] - async fn test_chat_message_validation(cx_a: &mut TestAppContext) { - cx_a.foreground().forbid_parking(); - - let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; - let client_a = server.create_client(cx_a, "user_a").await; - - let db = &server.app_state.db; - let org_id = db.create_org("Test Org", "test-org").await.unwrap(); - let channel_id = db.create_org_channel(org_id, "test-channel").await.unwrap(); - db.add_org_member(org_id, client_a.current_user_id(&cx_a), false) - .await - .unwrap(); - db.add_channel_member(channel_id, client_a.current_user_id(&cx_a), false) - .await - .unwrap(); - - let channels_a = cx_a - .add_model(|cx| ChannelList::new(client_a.user_store.clone(), client_a.clone(), cx)); - channels_a - .condition(cx_a, |list, _| list.available_channels().is_some()) - .await; - let channel_a = channels_a.update(cx_a, |this, cx| { - this.get_channel(channel_id.to_proto(), cx).unwrap() - }); - - // Messages aren't allowed to be too long. - channel_a - .update(cx_a, |channel, cx| { - let long_body = "this is long.\n".repeat(1024); - channel.send_message(long_body, cx).unwrap() - }) - .await - .unwrap_err(); - - // Messages aren't allowed to be blank. - channel_a.update(cx_a, |channel, cx| { - channel.send_message(String::new(), cx).unwrap_err() - }); - - // Leading and trailing whitespace are trimmed. - channel_a - .update(cx_a, |channel, cx| { - channel - .send_message("\n surrounded by whitespace \n".to_string(), cx) - .unwrap() - }) - .await - .unwrap(); - assert_eq!( - db.get_channel_messages(channel_id, 10, None) - .await - .unwrap() - .iter() - .map(|m| &m.body) - .collect::>(), - &["surrounded by whitespace"] - ); - } - - #[gpui::test(iterations = 10)] - async fn test_chat_reconnection(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { - cx_a.foreground().forbid_parking(); - - // Connect to a server as 2 clients. - let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; - let client_a = server.create_client(cx_a, "user_a").await; - let client_b = server.create_client(cx_b, "user_b").await; - let mut status_b = client_b.status(); - - // Create an org that includes these 2 users. - let db = &server.app_state.db; - let org_id = db.create_org("Test Org", "test-org").await.unwrap(); - db.add_org_member(org_id, client_a.current_user_id(&cx_a), false) - .await - .unwrap(); - db.add_org_member(org_id, client_b.current_user_id(&cx_b), false) - .await - .unwrap(); - - // Create a channel that includes all the users. - let channel_id = db.create_org_channel(org_id, "test-channel").await.unwrap(); - db.add_channel_member(channel_id, client_a.current_user_id(&cx_a), false) - .await - .unwrap(); - db.add_channel_member(channel_id, client_b.current_user_id(&cx_b), false) - .await - .unwrap(); - db.create_channel_message( - channel_id, - client_b.current_user_id(&cx_b), - "hello A, it's B.", - OffsetDateTime::now_utc(), - 2, - ) - .await - .unwrap(); - - let channels_a = cx_a - .add_model(|cx| ChannelList::new(client_a.user_store.clone(), client_a.clone(), cx)); - channels_a - .condition(cx_a, |list, _| list.available_channels().is_some()) - .await; - - channels_a.read_with(cx_a, |list, _| { - assert_eq!( - list.available_channels().unwrap(), - &[ChannelDetails { - id: channel_id.to_proto(), - name: "test-channel".to_string() - }] - ) - }); - let channel_a = channels_a.update(cx_a, |this, cx| { - this.get_channel(channel_id.to_proto(), cx).unwrap() - }); - channel_a.read_with(cx_a, |channel, _| assert!(channel.messages().is_empty())); - channel_a - .condition(&cx_a, |channel, _| { - channel_messages(channel) - == [("user_b".to_string(), "hello A, it's B.".to_string(), false)] - }) - .await; - - let channels_b = cx_b - .add_model(|cx| ChannelList::new(client_b.user_store.clone(), client_b.clone(), cx)); - channels_b - .condition(cx_b, |list, _| list.available_channels().is_some()) - .await; - channels_b.read_with(cx_b, |list, _| { - assert_eq!( - list.available_channels().unwrap(), - &[ChannelDetails { - id: channel_id.to_proto(), - name: "test-channel".to_string() - }] - ) - }); - - let channel_b = channels_b.update(cx_b, |this, cx| { - this.get_channel(channel_id.to_proto(), cx).unwrap() - }); - channel_b.read_with(cx_b, |channel, _| assert!(channel.messages().is_empty())); - channel_b - .condition(&cx_b, |channel, _| { - channel_messages(channel) - == [("user_b".to_string(), "hello A, it's B.".to_string(), false)] - }) - .await; - - // Disconnect client B, ensuring we can still access its cached channel data. - server.forbid_connections(); - server.disconnect_client(client_b.current_user_id(&cx_b)); - cx_b.foreground().advance_clock(rpc::RECEIVE_TIMEOUT); - while !matches!( - status_b.next().await, - Some(client::Status::ReconnectionError { .. }) - ) {} - - channels_b.read_with(cx_b, |channels, _| { - assert_eq!( - channels.available_channels().unwrap(), - [ChannelDetails { - id: channel_id.to_proto(), - name: "test-channel".to_string() - }] - ) - }); - channel_b.read_with(cx_b, |channel, _| { - assert_eq!( - channel_messages(channel), - [("user_b".to_string(), "hello A, it's B.".to_string(), false)] - ) - }); - - // Send a message from client B while it is disconnected. - channel_b - .update(cx_b, |channel, cx| { - let task = channel - .send_message("can you see this?".to_string(), cx) - .unwrap(); - assert_eq!( - channel_messages(channel), - &[ - ("user_b".to_string(), "hello A, it's B.".to_string(), false), - ("user_b".to_string(), "can you see this?".to_string(), true) - ] - ); - task - }) - .await - .unwrap_err(); - - // Send a message from client A while B is disconnected. - channel_a - .update(cx_a, |channel, cx| { - channel - .send_message("oh, hi B.".to_string(), cx) - .unwrap() - .detach(); - let task = channel.send_message("sup".to_string(), cx).unwrap(); - assert_eq!( - channel_messages(channel), - &[ - ("user_b".to_string(), "hello A, it's B.".to_string(), false), - ("user_a".to_string(), "oh, hi B.".to_string(), true), - ("user_a".to_string(), "sup".to_string(), true) - ] - ); - task - }) - .await - .unwrap(); - - // Give client B a chance to reconnect. - server.allow_connections(); - cx_b.foreground().advance_clock(Duration::from_secs(10)); - - // Verify that B sees the new messages upon reconnection, as well as the message client B - // sent while offline. - channel_b - .condition(&cx_b, |channel, _| { - channel_messages(channel) - == [ - ("user_b".to_string(), "hello A, it's B.".to_string(), false), - ("user_a".to_string(), "oh, hi B.".to_string(), false), - ("user_a".to_string(), "sup".to_string(), false), - ("user_b".to_string(), "can you see this?".to_string(), false), - ] - }) - .await; - - // Ensure client A and B can communicate normally after reconnection. - channel_a - .update(cx_a, |channel, cx| { - channel.send_message("you online?".to_string(), cx).unwrap() - }) - .await - .unwrap(); - channel_b - .condition(&cx_b, |channel, _| { - channel_messages(channel) - == [ - ("user_b".to_string(), "hello A, it's B.".to_string(), false), - ("user_a".to_string(), "oh, hi B.".to_string(), false), - ("user_a".to_string(), "sup".to_string(), false), - ("user_b".to_string(), "can you see this?".to_string(), false), - ("user_a".to_string(), "you online?".to_string(), false), - ] - }) - .await; - - channel_b - .update(cx_b, |channel, cx| { - channel.send_message("yep".to_string(), cx).unwrap() - }) - .await - .unwrap(); - channel_a - .condition(&cx_a, |channel, _| { - channel_messages(channel) - == [ - ("user_b".to_string(), "hello A, it's B.".to_string(), false), - ("user_a".to_string(), "oh, hi B.".to_string(), false), - ("user_a".to_string(), "sup".to_string(), false), - ("user_b".to_string(), "can you see this?".to_string(), false), - ("user_a".to_string(), "you online?".to_string(), false), - ("user_b".to_string(), "yep".to_string(), false), - ] - }) - .await; - } - - #[gpui::test(iterations = 10)] - async fn test_contacts( - deterministic: Arc, - cx_a: &mut TestAppContext, - cx_b: &mut TestAppContext, - cx_c: &mut TestAppContext, - ) { - cx_a.foreground().forbid_parking(); - - // Connect to a server as 3 clients. - let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; - let mut client_a = server.create_client(cx_a, "user_a").await; - let mut client_b = server.create_client(cx_b, "user_b").await; - let client_c = server.create_client(cx_c, "user_c").await; - server - .make_contacts(vec![ - (&client_a, cx_a), - (&client_b, cx_b), - (&client_c, cx_c), - ]) - .await; - - deterministic.run_until_parked(); - for (client, cx) in [(&client_a, &cx_a), (&client_b, &cx_b), (&client_c, &cx_c)] { - client.user_store.read_with(*cx, |store, _| { - assert_eq!( - contacts(store), - [ - ("user_a", true, vec![]), - ("user_b", true, vec![]), - ("user_c", true, vec![]) - ], - "{} has the wrong contacts", - client.username - ) - }); - } - - // Share a project as client A. - let fs = FakeFs::new(cx_a.background()); - fs.create_dir(Path::new("/a")).await.unwrap(); - let (project_a, _) = client_a.build_local_project(fs, "/a", cx_a).await; - - deterministic.run_until_parked(); - for (client, cx) in [(&client_a, &cx_a), (&client_b, &cx_b), (&client_c, &cx_c)] { - client.user_store.read_with(*cx, |store, _| { - assert_eq!( - contacts(store), - [ - ("user_a", true, vec![("a", vec![])]), - ("user_b", true, vec![]), - ("user_c", true, vec![]) - ], - "{} has the wrong contacts", - client.username - ) - }); - } - - let _project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; - - deterministic.run_until_parked(); - for (client, cx) in [(&client_a, &cx_a), (&client_b, &cx_b), (&client_c, &cx_c)] { - client.user_store.read_with(*cx, |store, _| { - assert_eq!( - contacts(store), - [ - ("user_a", true, vec![("a", vec!["user_b"])]), - ("user_b", true, vec![]), - ("user_c", true, vec![]) - ], - "{} has the wrong contacts", - client.username - ) - }); - } - - // Add a local project as client B - let fs = FakeFs::new(cx_b.background()); - fs.create_dir(Path::new("/b")).await.unwrap(); - let (_project_b, _) = client_b.build_local_project(fs, "/b", cx_a).await; - - deterministic.run_until_parked(); - for (client, cx) in [(&client_a, &cx_a), (&client_b, &cx_b), (&client_c, &cx_c)] { - client.user_store.read_with(*cx, |store, _| { - assert_eq!( - contacts(store), - [ - ("user_a", true, vec![("a", vec!["user_b"])]), - ("user_b", true, vec![("b", vec![])]), - ("user_c", true, vec![]) - ], - "{} has the wrong contacts", - client.username - ) - }); - } - - project_a - .condition(&cx_a, |project, _| { - project.collaborators().contains_key(&client_b.peer_id) - }) - .await; - - client_a.project.take(); - cx_a.update(move |_| drop(project_a)); - deterministic.run_until_parked(); - for (client, cx) in [(&client_a, &cx_a), (&client_b, &cx_b), (&client_c, &cx_c)] { - client.user_store.read_with(*cx, |store, _| { - assert_eq!( - contacts(store), - [ - ("user_a", true, vec![]), - ("user_b", true, vec![("b", vec![])]), - ("user_c", true, vec![]) - ], - "{} has the wrong contacts", - client.username - ) - }); - } - - server.disconnect_client(client_c.current_user_id(cx_c)); - server.forbid_connections(); - deterministic.advance_clock(rpc::RECEIVE_TIMEOUT); - for (client, cx) in [(&client_a, &cx_a), (&client_b, &cx_b)] { - client.user_store.read_with(*cx, |store, _| { - assert_eq!( - contacts(store), - [ - ("user_a", true, vec![]), - ("user_b", true, vec![("b", vec![])]), - ("user_c", false, vec![]) - ], - "{} has the wrong contacts", - client.username - ) - }); - } - client_c - .user_store - .read_with(cx_c, |store, _| assert_eq!(contacts(store), [])); - - server.allow_connections(); - client_c - .authenticate_and_connect(false, &cx_c.to_async()) - .await - .unwrap(); - - deterministic.run_until_parked(); - for (client, cx) in [(&client_a, &cx_a), (&client_b, &cx_b), (&client_c, &cx_c)] { - client.user_store.read_with(*cx, |store, _| { - assert_eq!( - contacts(store), - [ - ("user_a", true, vec![]), - ("user_b", true, vec![("b", vec![])]), - ("user_c", true, vec![]) - ], - "{} has the wrong contacts", - client.username - ) - }); - } - - fn contacts(user_store: &UserStore) -> Vec<(&str, bool, Vec<(&str, Vec<&str>)>)> { - user_store - .contacts() - .iter() - .map(|contact| { - let projects = contact - .projects - .iter() - .map(|p| { - ( - p.worktree_root_names[0].as_str(), - p.guests.iter().map(|p| p.github_login.as_str()).collect(), - ) - }) - .collect(); - (contact.user.github_login.as_str(), contact.online, projects) - }) - .collect() - } - } - - #[gpui::test(iterations = 10)] - async fn test_contact_requests( - executor: Arc, - cx_a: &mut TestAppContext, - cx_a2: &mut TestAppContext, - cx_b: &mut TestAppContext, - cx_b2: &mut TestAppContext, - cx_c: &mut TestAppContext, - cx_c2: &mut TestAppContext, - ) { - cx_a.foreground().forbid_parking(); - - // Connect to a server as 3 clients. - let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; - let client_a = server.create_client(cx_a, "user_a").await; - let client_a2 = server.create_client(cx_a2, "user_a").await; - let client_b = server.create_client(cx_b, "user_b").await; - let client_b2 = server.create_client(cx_b2, "user_b").await; - let client_c = server.create_client(cx_c, "user_c").await; - let client_c2 = server.create_client(cx_c2, "user_c").await; - - assert_eq!(client_a.user_id().unwrap(), client_a2.user_id().unwrap()); - assert_eq!(client_b.user_id().unwrap(), client_b2.user_id().unwrap()); - assert_eq!(client_c.user_id().unwrap(), client_c2.user_id().unwrap()); - - // User A and User C request that user B become their contact. - client_a - .user_store - .update(cx_a, |store, cx| { - store.request_contact(client_b.user_id().unwrap(), cx) - }) - .await - .unwrap(); - client_c - .user_store - .update(cx_c, |store, cx| { - store.request_contact(client_b.user_id().unwrap(), cx) - }) - .await - .unwrap(); - executor.run_until_parked(); - - // All users see the pending request appear in all their clients. - assert_eq!( - client_a.summarize_contacts(&cx_a).outgoing_requests, - &["user_b"] - ); - assert_eq!( - client_a2.summarize_contacts(&cx_a2).outgoing_requests, - &["user_b"] - ); - assert_eq!( - client_b.summarize_contacts(&cx_b).incoming_requests, - &["user_a", "user_c"] - ); - assert_eq!( - client_b2.summarize_contacts(&cx_b2).incoming_requests, - &["user_a", "user_c"] - ); - assert_eq!( - client_c.summarize_contacts(&cx_c).outgoing_requests, - &["user_b"] - ); - assert_eq!( - client_c2.summarize_contacts(&cx_c2).outgoing_requests, - &["user_b"] - ); - - // Contact requests are present upon connecting (tested here via disconnect/reconnect) - disconnect_and_reconnect(&client_a, cx_a).await; - disconnect_and_reconnect(&client_b, cx_b).await; - disconnect_and_reconnect(&client_c, cx_c).await; - executor.run_until_parked(); - assert_eq!( - client_a.summarize_contacts(&cx_a).outgoing_requests, - &["user_b"] - ); - assert_eq!( - client_b.summarize_contacts(&cx_b).incoming_requests, - &["user_a", "user_c"] - ); - assert_eq!( - client_c.summarize_contacts(&cx_c).outgoing_requests, - &["user_b"] - ); - - // User B accepts the request from user A. - client_b - .user_store - .update(cx_b, |store, cx| { - store.respond_to_contact_request(client_a.user_id().unwrap(), true, cx) - }) - .await - .unwrap(); - - executor.run_until_parked(); - - // User B sees user A as their contact now in all client, and the incoming request from them is removed. - let contacts_b = client_b.summarize_contacts(&cx_b); - assert_eq!(contacts_b.current, &["user_a", "user_b"]); - assert_eq!(contacts_b.incoming_requests, &["user_c"]); - let contacts_b2 = client_b2.summarize_contacts(&cx_b2); - assert_eq!(contacts_b2.current, &["user_a", "user_b"]); - assert_eq!(contacts_b2.incoming_requests, &["user_c"]); - - // User A sees user B as their contact now in all clients, and the outgoing request to them is removed. - let contacts_a = client_a.summarize_contacts(&cx_a); - assert_eq!(contacts_a.current, &["user_a", "user_b"]); - assert!(contacts_a.outgoing_requests.is_empty()); - let contacts_a2 = client_a2.summarize_contacts(&cx_a2); - assert_eq!(contacts_a2.current, &["user_a", "user_b"]); - assert!(contacts_a2.outgoing_requests.is_empty()); - - // Contacts are present upon connecting (tested here via disconnect/reconnect) - disconnect_and_reconnect(&client_a, cx_a).await; - disconnect_and_reconnect(&client_b, cx_b).await; - disconnect_and_reconnect(&client_c, cx_c).await; - executor.run_until_parked(); - assert_eq!( - client_a.summarize_contacts(&cx_a).current, - &["user_a", "user_b"] - ); - assert_eq!( - client_b.summarize_contacts(&cx_b).current, - &["user_a", "user_b"] - ); - assert_eq!( - client_b.summarize_contacts(&cx_b).incoming_requests, - &["user_c"] - ); - assert_eq!(client_c.summarize_contacts(&cx_c).current, &["user_c"]); - assert_eq!( - client_c.summarize_contacts(&cx_c).outgoing_requests, - &["user_b"] - ); - - // User B rejects the request from user C. - client_b - .user_store - .update(cx_b, |store, cx| { - store.respond_to_contact_request(client_c.user_id().unwrap(), false, cx) - }) - .await - .unwrap(); - - executor.run_until_parked(); - - // User B doesn't see user C as their contact, and the incoming request from them is removed. - let contacts_b = client_b.summarize_contacts(&cx_b); - assert_eq!(contacts_b.current, &["user_a", "user_b"]); - assert!(contacts_b.incoming_requests.is_empty()); - let contacts_b2 = client_b2.summarize_contacts(&cx_b2); - assert_eq!(contacts_b2.current, &["user_a", "user_b"]); - assert!(contacts_b2.incoming_requests.is_empty()); - - // User C doesn't see user B as their contact, and the outgoing request to them is removed. - let contacts_c = client_c.summarize_contacts(&cx_c); - assert_eq!(contacts_c.current, &["user_c"]); - assert!(contacts_c.outgoing_requests.is_empty()); - let contacts_c2 = client_c2.summarize_contacts(&cx_c2); - assert_eq!(contacts_c2.current, &["user_c"]); - assert!(contacts_c2.outgoing_requests.is_empty()); - - // Incoming/outgoing requests are not present upon connecting (tested here via disconnect/reconnect) - disconnect_and_reconnect(&client_a, cx_a).await; - disconnect_and_reconnect(&client_b, cx_b).await; - disconnect_and_reconnect(&client_c, cx_c).await; - executor.run_until_parked(); - assert_eq!( - client_a.summarize_contacts(&cx_a).current, - &["user_a", "user_b"] - ); - assert_eq!( - client_b.summarize_contacts(&cx_b).current, - &["user_a", "user_b"] - ); - assert!(client_b - .summarize_contacts(&cx_b) - .incoming_requests - .is_empty()); - assert_eq!(client_c.summarize_contacts(&cx_c).current, &["user_c"]); - assert!(client_c - .summarize_contacts(&cx_c) - .outgoing_requests - .is_empty()); - - async fn disconnect_and_reconnect(client: &TestClient, cx: &mut TestAppContext) { - client.disconnect(&cx.to_async()).unwrap(); - client.clear_contacts(cx).await; - client - .authenticate_and_connect(false, &cx.to_async()) - .await - .unwrap(); - } - } - - #[gpui::test(iterations = 10)] - async fn test_following(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { - cx_a.foreground().forbid_parking(); - let fs = FakeFs::new(cx_a.background()); - - // 2 clients connect to a server. - let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; - let mut client_a = server.create_client(cx_a, "user_a").await; - let mut client_b = server.create_client(cx_b, "user_b").await; - server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) - .await; - cx_a.update(editor::init); - cx_b.update(editor::init); - - // Client A shares a project. - fs.insert_tree( - "/a", - json!({ - "1.txt": "one", - "2.txt": "two", - "3.txt": "three", - }), - ) - .await; - let (project_a, worktree_id) = client_a.build_local_project(fs.clone(), "/a", cx_a).await; - - // Client B joins the project. - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; - - // Client A opens some editors. - let workspace_a = client_a.build_workspace(&project_a, cx_a); - let pane_a = workspace_a.read_with(cx_a, |workspace, _| workspace.active_pane().clone()); - let editor_a1 = workspace_a - .update(cx_a, |workspace, cx| { - workspace.open_path((worktree_id, "1.txt"), true, cx) - }) - .await - .unwrap() - .downcast::() - .unwrap(); - let editor_a2 = workspace_a - .update(cx_a, |workspace, cx| { - workspace.open_path((worktree_id, "2.txt"), true, cx) - }) - .await - .unwrap() - .downcast::() - .unwrap(); - - // Client B opens an editor. - let workspace_b = client_b.build_workspace(&project_b, cx_b); - let editor_b1 = workspace_b - .update(cx_b, |workspace, cx| { - workspace.open_path((worktree_id, "1.txt"), true, cx) - }) - .await - .unwrap() - .downcast::() - .unwrap(); - - let client_a_id = project_b.read_with(cx_b, |project, _| { - project.collaborators().values().next().unwrap().peer_id - }); - let client_b_id = project_a.read_with(cx_a, |project, _| { - project.collaborators().values().next().unwrap().peer_id - }); - - // When client B starts following client A, all visible view states are replicated to client B. - editor_a1.update(cx_a, |editor, cx| { - editor.change_selections(None, cx, |s| s.select_ranges([0..1])) - }); - editor_a2.update(cx_a, |editor, cx| { - editor.change_selections(None, cx, |s| s.select_ranges([2..3])) - }); - workspace_b - .update(cx_b, |workspace, cx| { - workspace - .toggle_follow(&ToggleFollow(client_a_id), cx) - .unwrap() - }) - .await - .unwrap(); - - let editor_b2 = workspace_b.read_with(cx_b, |workspace, cx| { - workspace - .active_item(cx) - .unwrap() - .downcast::() - .unwrap() - }); - assert!(cx_b.read(|cx| editor_b2.is_focused(cx))); - assert_eq!( - editor_b2.read_with(cx_b, |editor, cx| editor.project_path(cx)), - Some((worktree_id, "2.txt").into()) - ); - assert_eq!( - editor_b2.read_with(cx_b, |editor, cx| editor.selections.ranges(cx)), - vec![2..3] - ); - assert_eq!( - editor_b1.read_with(cx_b, |editor, cx| editor.selections.ranges(cx)), - vec![0..1] - ); - - // When client A activates a different editor, client B does so as well. - workspace_a.update(cx_a, |workspace, cx| { - workspace.activate_item(&editor_a1, cx) - }); - workspace_b - .condition(cx_b, |workspace, cx| { - workspace.active_item(cx).unwrap().id() == editor_b1.id() - }) - .await; - - // When client A navigates back and forth, client B does so as well. - workspace_a - .update(cx_a, |workspace, cx| { - workspace::Pane::go_back(workspace, None, cx) - }) - .await; - workspace_b - .condition(cx_b, |workspace, cx| { - workspace.active_item(cx).unwrap().id() == editor_b2.id() - }) - .await; - - workspace_a - .update(cx_a, |workspace, cx| { - workspace::Pane::go_forward(workspace, None, cx) - }) - .await; - workspace_b - .condition(cx_b, |workspace, cx| { - workspace.active_item(cx).unwrap().id() == editor_b1.id() - }) - .await; - - // Changes to client A's editor are reflected on client B. - editor_a1.update(cx_a, |editor, cx| { - editor.change_selections(None, cx, |s| s.select_ranges([1..1, 2..2])); - }); - editor_b1 - .condition(cx_b, |editor, cx| { - editor.selections.ranges(cx) == vec![1..1, 2..2] - }) - .await; - - editor_a1.update(cx_a, |editor, cx| editor.set_text("TWO", cx)); - editor_b1 - .condition(cx_b, |editor, cx| editor.text(cx) == "TWO") - .await; - - editor_a1.update(cx_a, |editor, cx| { - editor.change_selections(None, cx, |s| s.select_ranges([3..3])); - editor.set_scroll_position(vec2f(0., 100.), cx); - }); - editor_b1 - .condition(cx_b, |editor, cx| { - editor.selections.ranges(cx) == vec![3..3] - }) - .await; - - // After unfollowing, client B stops receiving updates from client A. - workspace_b.update(cx_b, |workspace, cx| { - workspace.unfollow(&workspace.active_pane().clone(), cx) - }); - workspace_a.update(cx_a, |workspace, cx| { - workspace.activate_item(&editor_a2, cx) - }); - cx_a.foreground().run_until_parked(); - assert_eq!( - workspace_b.read_with(cx_b, |workspace, cx| workspace - .active_item(cx) - .unwrap() - .id()), - editor_b1.id() - ); - - // Client A starts following client B. - workspace_a - .update(cx_a, |workspace, cx| { - workspace - .toggle_follow(&ToggleFollow(client_b_id), cx) - .unwrap() - }) - .await - .unwrap(); - assert_eq!( - workspace_a.read_with(cx_a, |workspace, _| workspace.leader_for_pane(&pane_a)), - Some(client_b_id) - ); - assert_eq!( - workspace_a.read_with(cx_a, |workspace, cx| workspace - .active_item(cx) - .unwrap() - .id()), - editor_a1.id() - ); - - // Following interrupts when client B disconnects. - client_b.disconnect(&cx_b.to_async()).unwrap(); - cx_a.foreground().run_until_parked(); - assert_eq!( - workspace_a.read_with(cx_a, |workspace, _| workspace.leader_for_pane(&pane_a)), - None - ); - } - - #[gpui::test(iterations = 10)] - async fn test_peers_following_each_other(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { - cx_a.foreground().forbid_parking(); - let fs = FakeFs::new(cx_a.background()); - - // 2 clients connect to a server. - let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; - let mut client_a = server.create_client(cx_a, "user_a").await; - let mut client_b = server.create_client(cx_b, "user_b").await; - server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) - .await; - cx_a.update(editor::init); - cx_b.update(editor::init); - - // Client A shares a project. - fs.insert_tree( - "/a", - json!({ - "1.txt": "one", - "2.txt": "two", - "3.txt": "three", - "4.txt": "four", - }), - ) - .await; - let (project_a, worktree_id) = client_a.build_local_project(fs.clone(), "/a", cx_a).await; - - // Client B joins the project. - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; - - // Client A opens some editors. - let workspace_a = client_a.build_workspace(&project_a, cx_a); - let pane_a1 = workspace_a.read_with(cx_a, |workspace, _| workspace.active_pane().clone()); - let _editor_a1 = workspace_a - .update(cx_a, |workspace, cx| { - workspace.open_path((worktree_id, "1.txt"), true, cx) - }) - .await - .unwrap() - .downcast::() - .unwrap(); - - // Client B opens an editor. - let workspace_b = client_b.build_workspace(&project_b, cx_b); - let pane_b1 = workspace_b.read_with(cx_b, |workspace, _| workspace.active_pane().clone()); - let _editor_b1 = workspace_b - .update(cx_b, |workspace, cx| { - workspace.open_path((worktree_id, "2.txt"), true, cx) - }) - .await - .unwrap() - .downcast::() - .unwrap(); - - // Clients A and B follow each other in split panes - workspace_a - .update(cx_a, |workspace, cx| { - workspace.split_pane(workspace.active_pane().clone(), SplitDirection::Right, cx); - assert_ne!(*workspace.active_pane(), pane_a1); - let leader_id = *project_a.read(cx).collaborators().keys().next().unwrap(); - workspace - .toggle_follow(&workspace::ToggleFollow(leader_id), cx) - .unwrap() - }) - .await - .unwrap(); - workspace_b - .update(cx_b, |workspace, cx| { - workspace.split_pane(workspace.active_pane().clone(), SplitDirection::Right, cx); - assert_ne!(*workspace.active_pane(), pane_b1); - let leader_id = *project_b.read(cx).collaborators().keys().next().unwrap(); - workspace - .toggle_follow(&workspace::ToggleFollow(leader_id), cx) - .unwrap() - }) - .await - .unwrap(); - - workspace_a - .update(cx_a, |workspace, cx| { - workspace.activate_next_pane(cx); - assert_eq!(*workspace.active_pane(), pane_a1); - workspace.open_path((worktree_id, "3.txt"), true, cx) - }) - .await - .unwrap(); - workspace_b - .update(cx_b, |workspace, cx| { - workspace.activate_next_pane(cx); - assert_eq!(*workspace.active_pane(), pane_b1); - workspace.open_path((worktree_id, "4.txt"), true, cx) - }) - .await - .unwrap(); - cx_a.foreground().run_until_parked(); - - // Ensure leader updates don't change the active pane of followers - workspace_a.read_with(cx_a, |workspace, _| { - assert_eq!(*workspace.active_pane(), pane_a1); - }); - workspace_b.read_with(cx_b, |workspace, _| { - assert_eq!(*workspace.active_pane(), pane_b1); - }); - - // Ensure peers following each other doesn't cause an infinite loop. - assert_eq!( - workspace_a.read_with(cx_a, |workspace, cx| workspace - .active_item(cx) - .unwrap() - .project_path(cx)), - Some((worktree_id, "3.txt").into()) - ); - workspace_a.update(cx_a, |workspace, cx| { - assert_eq!( - workspace.active_item(cx).unwrap().project_path(cx), - Some((worktree_id, "3.txt").into()) - ); - workspace.activate_next_pane(cx); - assert_eq!( - workspace.active_item(cx).unwrap().project_path(cx), - Some((worktree_id, "4.txt").into()) - ); - }); - workspace_b.update(cx_b, |workspace, cx| { - assert_eq!( - workspace.active_item(cx).unwrap().project_path(cx), - Some((worktree_id, "4.txt").into()) - ); - workspace.activate_next_pane(cx); - assert_eq!( - workspace.active_item(cx).unwrap().project_path(cx), - Some((worktree_id, "3.txt").into()) - ); - }); - } - - #[gpui::test(iterations = 10)] - async fn test_auto_unfollowing(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { - cx_a.foreground().forbid_parking(); - let fs = FakeFs::new(cx_a.background()); - - // 2 clients connect to a server. - let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; - let mut client_a = server.create_client(cx_a, "user_a").await; - let mut client_b = server.create_client(cx_b, "user_b").await; - server - .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) - .await; - cx_a.update(editor::init); - cx_b.update(editor::init); - - // Client A shares a project. - fs.insert_tree( - "/a", - json!({ - "1.txt": "one", - "2.txt": "two", - "3.txt": "three", - }), - ) - .await; - let (project_a, worktree_id) = client_a.build_local_project(fs.clone(), "/a", cx_a).await; - - // Client B joins the project. - let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; - - // Client A opens some editors. - let workspace_a = client_a.build_workspace(&project_a, cx_a); - let _editor_a1 = workspace_a - .update(cx_a, |workspace, cx| { - workspace.open_path((worktree_id, "1.txt"), true, cx) - }) - .await - .unwrap() - .downcast::() - .unwrap(); - - // Client B starts following client A. - let workspace_b = client_b.build_workspace(&project_b, cx_b); - let pane_b = workspace_b.read_with(cx_b, |workspace, _| workspace.active_pane().clone()); - let leader_id = project_b.read_with(cx_b, |project, _| { - project.collaborators().values().next().unwrap().peer_id - }); - workspace_b - .update(cx_b, |workspace, cx| { - workspace - .toggle_follow(&ToggleFollow(leader_id), cx) - .unwrap() - }) - .await - .unwrap(); - assert_eq!( - workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), - Some(leader_id) - ); - let editor_b2 = workspace_b.read_with(cx_b, |workspace, cx| { - workspace - .active_item(cx) - .unwrap() - .downcast::() - .unwrap() - }); - - // When client B moves, it automatically stops following client A. - editor_b2.update(cx_b, |editor, cx| editor.move_right(&editor::MoveRight, cx)); - assert_eq!( - workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), - None - ); - - workspace_b - .update(cx_b, |workspace, cx| { - workspace - .toggle_follow(&ToggleFollow(leader_id), cx) - .unwrap() - }) - .await - .unwrap(); - assert_eq!( - workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), - Some(leader_id) - ); - - // When client B edits, it automatically stops following client A. - editor_b2.update(cx_b, |editor, cx| editor.insert("X", cx)); - assert_eq!( - workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), - None - ); - - workspace_b - .update(cx_b, |workspace, cx| { - workspace - .toggle_follow(&ToggleFollow(leader_id), cx) - .unwrap() - }) - .await - .unwrap(); - assert_eq!( - workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), - Some(leader_id) - ); - - // When client B scrolls, it automatically stops following client A. - editor_b2.update(cx_b, |editor, cx| { - editor.set_scroll_position(vec2f(0., 3.), cx) - }); - assert_eq!( - workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), - None - ); - - workspace_b - .update(cx_b, |workspace, cx| { - workspace - .toggle_follow(&ToggleFollow(leader_id), cx) - .unwrap() - }) - .await - .unwrap(); - assert_eq!( - workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), - Some(leader_id) - ); - - // When client B activates a different pane, it continues following client A in the original pane. - workspace_b.update(cx_b, |workspace, cx| { - workspace.split_pane(pane_b.clone(), SplitDirection::Right, cx) - }); - assert_eq!( - workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), - Some(leader_id) - ); - - workspace_b.update(cx_b, |workspace, cx| workspace.activate_next_pane(cx)); - assert_eq!( - workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), - Some(leader_id) - ); - - // When client B activates a different item in the original pane, it automatically stops following client A. - workspace_b - .update(cx_b, |workspace, cx| { - workspace.open_path((worktree_id, "2.txt"), true, cx) - }) - .await - .unwrap(); - assert_eq!( - workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), - None - ); - } - - #[gpui::test(iterations = 100)] - async fn test_random_collaboration( - cx: &mut TestAppContext, - deterministic: Arc, - rng: StdRng, - ) { - cx.foreground().forbid_parking(); - let max_peers = env::var("MAX_PEERS") - .map(|i| i.parse().expect("invalid `MAX_PEERS` variable")) - .unwrap_or(5); - assert!(max_peers <= 5); - - let max_operations = env::var("OPERATIONS") - .map(|i| i.parse().expect("invalid `OPERATIONS` variable")) - .unwrap_or(10); - - let rng = Arc::new(Mutex::new(rng)); - - let guest_lang_registry = Arc::new(LanguageRegistry::test()); - let host_language_registry = Arc::new(LanguageRegistry::test()); - - let fs = FakeFs::new(cx.background()); - fs.insert_tree("/_collab", json!({"init": ""})).await; - - let mut server = TestServer::start(cx.foreground(), cx.background()).await; - let db = server.app_state.db.clone(); - let host_user_id = db.create_user("host", None, false).await.unwrap(); - for username in ["guest-1", "guest-2", "guest-3", "guest-4"] { - let guest_user_id = db.create_user(username, None, false).await.unwrap(); - server - .app_state - .db - .send_contact_request(guest_user_id, host_user_id) - .await - .unwrap(); - server - .app_state - .db - .respond_to_contact_request(host_user_id, guest_user_id, true) - .await - .unwrap(); - } - - let mut clients = Vec::new(); - let mut user_ids = Vec::new(); - let mut op_start_signals = Vec::new(); - - let mut next_entity_id = 100000; - let mut host_cx = TestAppContext::new( - cx.foreground_platform(), - cx.platform(), - deterministic.build_foreground(next_entity_id), - deterministic.build_background(), - cx.font_cache(), - cx.leak_detector(), - next_entity_id, - ); - let host = server.create_client(&mut host_cx, "host").await; - let host_project = host_cx.update(|cx| { - Project::local( - host.client.clone(), - host.user_store.clone(), - host_language_registry.clone(), - fs.clone(), - cx, - ) - }); - let host_project_id = host_project - .update(&mut host_cx, |p, _| p.next_remote_id()) - .await; - - let (collab_worktree, _) = host_project - .update(&mut host_cx, |project, cx| { - project.find_or_create_local_worktree("/_collab", true, cx) - }) - .await - .unwrap(); - collab_worktree - .read_with(&host_cx, |tree, _| tree.as_local().unwrap().scan_complete()) - .await; - - // Set up fake language servers. - let mut language = Language::new( - LanguageConfig { - name: "Rust".into(), - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - None, - ); - let _fake_servers = language.set_fake_lsp_adapter(FakeLspAdapter { - name: "the-fake-language-server", - capabilities: lsp::LanguageServer::full_capabilities(), - initializer: Some(Box::new({ - let rng = rng.clone(); - let fs = fs.clone(); - let project = host_project.downgrade(); - move |fake_server: &mut FakeLanguageServer| { - fake_server.handle_request::( - |_, _| async move { - Ok(Some(lsp::CompletionResponse::Array(vec![ - lsp::CompletionItem { - text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { - range: lsp::Range::new( - lsp::Position::new(0, 0), - lsp::Position::new(0, 0), - ), - new_text: "the-new-text".to_string(), - })), - ..Default::default() - }, - ]))) - }, - ); - - fake_server.handle_request::( - |_, _| async move { - Ok(Some(vec![lsp::CodeActionOrCommand::CodeAction( - lsp::CodeAction { - title: "the-code-action".to_string(), - ..Default::default() - }, - )])) - }, - ); - - fake_server.handle_request::( - |params, _| async move { - Ok(Some(lsp::PrepareRenameResponse::Range(lsp::Range::new( - params.position, - params.position, - )))) - }, - ); - - fake_server.handle_request::({ - let fs = fs.clone(); - let rng = rng.clone(); - move |_, _| { - let fs = fs.clone(); - let rng = rng.clone(); - async move { - let files = fs.files().await; - let mut rng = rng.lock(); - let count = rng.gen_range::(1..3); - let files = (0..count) - .map(|_| files.choose(&mut *rng).unwrap()) - .collect::>(); - log::info!("LSP: Returning definitions in files {:?}", &files); - Ok(Some(lsp::GotoDefinitionResponse::Array( - files - .into_iter() - .map(|file| lsp::Location { - uri: lsp::Url::from_file_path(file).unwrap(), - range: Default::default(), - }) - .collect(), - ))) - } - } - }); - - fake_server.handle_request::({ - let rng = rng.clone(); - let project = project.clone(); - move |params, mut cx| { - let highlights = if let Some(project) = project.upgrade(&cx) { - project.update(&mut cx, |project, cx| { - let path = params - .text_document_position_params - .text_document - .uri - .to_file_path() - .unwrap(); - let (worktree, relative_path) = - project.find_local_worktree(&path, cx)?; - let project_path = - ProjectPath::from((worktree.read(cx).id(), relative_path)); - let buffer = - project.get_open_buffer(&project_path, cx)?.read(cx); - - let mut highlights = Vec::new(); - let highlight_count = rng.lock().gen_range(1..=5); - let mut prev_end = 0; - for _ in 0..highlight_count { - let range = - buffer.random_byte_range(prev_end, &mut *rng.lock()); - - highlights.push(lsp::DocumentHighlight { - range: range_to_lsp(range.to_point_utf16(buffer)), - kind: Some(lsp::DocumentHighlightKind::READ), - }); - prev_end = range.end; - } - Some(highlights) - }) - } else { - None - }; - async move { Ok(highlights) } - } - }); - } - })), - ..Default::default() - }); - host_language_registry.add(Arc::new(language)); - - let op_start_signal = futures::channel::mpsc::unbounded(); - user_ids.push(host.current_user_id(&host_cx)); - op_start_signals.push(op_start_signal.0); - clients.push(host_cx.foreground().spawn(host.simulate_host( - host_project, - op_start_signal.1, - rng.clone(), - host_cx, - ))); - - let disconnect_host_at = if rng.lock().gen_bool(0.2) { - rng.lock().gen_range(0..max_operations) - } else { - max_operations - }; - let mut available_guests = vec![ - "guest-1".to_string(), - "guest-2".to_string(), - "guest-3".to_string(), - "guest-4".to_string(), - ]; - let mut operations = 0; - while operations < max_operations { - if operations == disconnect_host_at { - server.disconnect_client(user_ids[0]); - cx.foreground().advance_clock(RECEIVE_TIMEOUT); - drop(op_start_signals); - let mut clients = futures::future::join_all(clients).await; - cx.foreground().run_until_parked(); - - let (host, mut host_cx, host_err) = clients.remove(0); - if let Some(host_err) = host_err { - log::error!("host error - {:?}", host_err); - } - host.project - .as_ref() - .unwrap() - .read_with(&host_cx, |project, _| assert!(!project.is_shared())); - for (guest, mut guest_cx, guest_err) in clients { - if let Some(guest_err) = guest_err { - log::error!("{} error - {:?}", guest.username, guest_err); - } - - let contacts = server - .app_state - .db - .get_contacts(guest.current_user_id(&guest_cx)) - .await - .unwrap(); - let contacts = server - .store - .read() - .await - .build_initial_contacts_update(contacts) - .contacts; - assert!(!contacts - .iter() - .flat_map(|contact| &contact.projects) - .any(|project| project.id == host_project_id)); - guest - .project - .as_ref() - .unwrap() - .read_with(&guest_cx, |project, _| assert!(project.is_read_only())); - guest_cx.update(|_| drop(guest)); - } - host_cx.update(|_| drop(host)); - - return; - } - - let distribution = rng.lock().gen_range(0..100); - match distribution { - 0..=19 if !available_guests.is_empty() => { - let guest_ix = rng.lock().gen_range(0..available_guests.len()); - let guest_username = available_guests.remove(guest_ix); - log::info!("Adding new connection for {}", guest_username); - next_entity_id += 100000; - let mut guest_cx = TestAppContext::new( - cx.foreground_platform(), - cx.platform(), - deterministic.build_foreground(next_entity_id), - deterministic.build_background(), - cx.font_cache(), - cx.leak_detector(), - next_entity_id, - ); - let guest = server.create_client(&mut guest_cx, &guest_username).await; - let guest_project = Project::remote( - host_project_id, - guest.client.clone(), - guest.user_store.clone(), - guest_lang_registry.clone(), - FakeFs::new(cx.background()), - &mut guest_cx.to_async(), - ) - .await - .unwrap(); - let op_start_signal = futures::channel::mpsc::unbounded(); - user_ids.push(guest.current_user_id(&guest_cx)); - op_start_signals.push(op_start_signal.0); - clients.push(guest_cx.foreground().spawn(guest.simulate_guest( - guest_username.clone(), - guest_project, - op_start_signal.1, - rng.clone(), - guest_cx, - ))); - - log::info!("Added connection for {}", guest_username); - operations += 1; - } - 20..=29 if clients.len() > 1 => { - let guest_ix = rng.lock().gen_range(1..clients.len()); - log::info!("Removing guest {}", user_ids[guest_ix]); - let removed_guest_id = user_ids.remove(guest_ix); - let guest = clients.remove(guest_ix); - op_start_signals.remove(guest_ix); - server.forbid_connections(); - server.disconnect_client(removed_guest_id); - cx.foreground().advance_clock(RECEIVE_TIMEOUT); - let (guest, mut guest_cx, guest_err) = guest.await; - server.allow_connections(); - - if let Some(guest_err) = guest_err { - log::error!("{} error - {:?}", guest.username, guest_err); - } - guest - .project - .as_ref() - .unwrap() - .read_with(&guest_cx, |project, _| assert!(project.is_read_only())); - for user_id in &user_ids { - let contacts = server.app_state.db.get_contacts(*user_id).await.unwrap(); - let contacts = server - .store - .read() - .await - .build_initial_contacts_update(contacts) - .contacts; - for contact in contacts { - if contact.online { - assert_ne!( - contact.user_id, removed_guest_id.0 as u64, - "removed guest is still a contact of another peer" - ); - } - for project in contact.projects { - for project_guest_id in project.guests { - assert_ne!( - project_guest_id, removed_guest_id.0 as u64, - "removed guest appears as still participating on a project" - ); - } - } - } - } - - log::info!("{} removed", guest.username); - available_guests.push(guest.username.clone()); - guest_cx.update(|_| drop(guest)); - - operations += 1; - } - _ => { - while operations < max_operations && rng.lock().gen_bool(0.7) { - op_start_signals - .choose(&mut *rng.lock()) - .unwrap() - .unbounded_send(()) - .unwrap(); - operations += 1; - } - - if rng.lock().gen_bool(0.8) { - cx.foreground().run_until_parked(); - } - } - } - } - - drop(op_start_signals); - let mut clients = futures::future::join_all(clients).await; - cx.foreground().run_until_parked(); - - let (host_client, mut host_cx, host_err) = clients.remove(0); - if let Some(host_err) = host_err { - panic!("host error - {:?}", host_err); - } - let host_project = host_client.project.as_ref().unwrap(); - let host_worktree_snapshots = host_project.read_with(&host_cx, |project, cx| { - project - .worktrees(cx) - .map(|worktree| { - let snapshot = worktree.read(cx).snapshot(); - (snapshot.id(), snapshot) - }) - .collect::>() - }); - - host_client - .project - .as_ref() - .unwrap() - .read_with(&host_cx, |project, cx| project.check_invariants(cx)); - - for (guest_client, mut guest_cx, guest_err) in clients.into_iter() { - if let Some(guest_err) = guest_err { - panic!("{} error - {:?}", guest_client.username, guest_err); - } - let worktree_snapshots = - guest_client - .project - .as_ref() - .unwrap() - .read_with(&guest_cx, |project, cx| { - project - .worktrees(cx) - .map(|worktree| { - let worktree = worktree.read(cx); - (worktree.id(), worktree.snapshot()) - }) - .collect::>() - }); - - assert_eq!( - worktree_snapshots.keys().collect::>(), - host_worktree_snapshots.keys().collect::>(), - "{} has different worktrees than the host", - guest_client.username - ); - for (id, host_snapshot) in &host_worktree_snapshots { - let guest_snapshot = &worktree_snapshots[id]; - assert_eq!( - guest_snapshot.root_name(), - host_snapshot.root_name(), - "{} has different root name than the host for worktree {}", - guest_client.username, - id - ); - assert_eq!( - guest_snapshot.entries(false).collect::>(), - host_snapshot.entries(false).collect::>(), - "{} has different snapshot than the host for worktree {}", - guest_client.username, - id - ); - assert_eq!(guest_snapshot.scan_id(), host_snapshot.scan_id()); - } - - guest_client - .project - .as_ref() - .unwrap() - .read_with(&guest_cx, |project, cx| project.check_invariants(cx)); - - for guest_buffer in &guest_client.buffers { - let buffer_id = guest_buffer.read_with(&guest_cx, |buffer, _| buffer.remote_id()); - let host_buffer = host_project.read_with(&host_cx, |project, cx| { - project.buffer_for_id(buffer_id, cx).expect(&format!( - "host does not have buffer for guest:{}, peer:{}, id:{}", - guest_client.username, guest_client.peer_id, buffer_id - )) - }); - let path = host_buffer - .read_with(&host_cx, |buffer, cx| buffer.file().unwrap().full_path(cx)); - - assert_eq!( - guest_buffer.read_with(&guest_cx, |buffer, _| buffer.deferred_ops_len()), - 0, - "{}, buffer {}, path {:?} has deferred operations", - guest_client.username, - buffer_id, - path, - ); - assert_eq!( - guest_buffer.read_with(&guest_cx, |buffer, _| buffer.text()), - host_buffer.read_with(&host_cx, |buffer, _| buffer.text()), - "{}, buffer {}, path {:?}, differs from the host's buffer", - guest_client.username, - buffer_id, - path - ); - } - - guest_cx.update(|_| drop(guest_client)); - } - - host_cx.update(|_| drop(host_client)); - } - - struct TestServer { - peer: Arc, - app_state: Arc, - server: Arc, - foreground: Rc, - notifications: mpsc::UnboundedReceiver<()>, - connection_killers: Arc>>>, - forbid_connections: Arc, - _test_db: TestDb, - } - - impl TestServer { - async fn start( - foreground: Rc, - background: Arc, - ) -> Self { - let test_db = TestDb::fake(background); - let app_state = Self::build_app_state(&test_db).await; - let peer = Peer::new(); - let notifications = mpsc::unbounded(); - let server = Server::new(app_state.clone(), Some(notifications.0)); - Self { - peer, - app_state, - server, - foreground, - notifications: notifications.1, - connection_killers: Default::default(), - forbid_connections: Default::default(), - _test_db: test_db, - } - } - - async fn create_client(&mut self, cx: &mut TestAppContext, name: &str) -> TestClient { - cx.update(|cx| { - let settings = Settings::test(cx); - cx.set_global(settings); - }); - - let http = FakeHttpClient::with_404_response(); - let user_id = - if let Ok(Some(user)) = self.app_state.db.get_user_by_github_login(name).await { - user.id - } else { - self.app_state.db.create_user(name, None, false).await.unwrap() - }; - let client_name = name.to_string(); - let mut client = Client::new(http.clone()); - let server = self.server.clone(); - let db = self.app_state.db.clone(); - let connection_killers = self.connection_killers.clone(); - let forbid_connections = self.forbid_connections.clone(); - let (connection_id_tx, mut connection_id_rx) = mpsc::channel(16); - - Arc::get_mut(&mut client) - .unwrap() - .override_authenticate(move |cx| { - cx.spawn(|_| async move { - let access_token = "the-token".to_string(); - Ok(Credentials { - user_id: user_id.0 as u64, - access_token, - }) - }) - }) - .override_establish_connection(move |credentials, cx| { - assert_eq!(credentials.user_id, user_id.0 as u64); - assert_eq!(credentials.access_token, "the-token"); - - let server = server.clone(); - let db = db.clone(); - let connection_killers = connection_killers.clone(); - let forbid_connections = forbid_connections.clone(); - let client_name = client_name.clone(); - let connection_id_tx = connection_id_tx.clone(); - cx.spawn(move |cx| async move { - if forbid_connections.load(SeqCst) { - Err(EstablishConnectionError::other(anyhow!( - "server is forbidding connections" - ))) - } else { - let (client_conn, server_conn, killed) = - Connection::in_memory(cx.background()); - connection_killers.lock().insert(user_id, killed); - let user = db.get_user_by_id(user_id).await.unwrap().unwrap(); - cx.background() - .spawn(server.handle_connection( - server_conn, - client_name, - user, - Some(connection_id_tx), - cx.background(), - )) - .detach(); - Ok(client_conn) - } - }) - }); - - let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http, cx)); - let app_state = Arc::new(workspace::AppState { - client: client.clone(), - user_store: user_store.clone(), - languages: Arc::new(LanguageRegistry::new(Task::ready(()))), - themes: ThemeRegistry::new((), cx.font_cache()), - fs: FakeFs::new(cx.background()), - build_window_options: || Default::default(), - initialize_workspace: |_, _, _| unimplemented!(), - }); - - Channel::init(&client); - Project::init(&client); - cx.update(|cx| workspace::init(app_state.clone(), cx)); - - client - .authenticate_and_connect(false, &cx.to_async()) - .await - .unwrap(); - let peer_id = PeerId(connection_id_rx.next().await.unwrap().0); - - let client = TestClient { - client, - peer_id, - username: name.to_string(), - user_store, - language_registry: Arc::new(LanguageRegistry::test()), - project: Default::default(), - buffers: Default::default(), - }; - client.wait_for_current_user(cx).await; - client - } - - fn disconnect_client(&self, user_id: UserId) { - self.connection_killers - .lock() - .remove(&user_id) - .unwrap() - .store(true, SeqCst); - } - - fn forbid_connections(&self) { - self.forbid_connections.store(true, SeqCst); - } - - fn allow_connections(&self) { - self.forbid_connections.store(false, SeqCst); - } - - async fn make_contacts(&self, mut clients: Vec<(&TestClient, &mut TestAppContext)>) { - while let Some((client_a, cx_a)) = clients.pop() { - for (client_b, cx_b) in &mut clients { - client_a - .user_store - .update(cx_a, |store, cx| { - store.request_contact(client_b.user_id().unwrap(), cx) - }) - .await - .unwrap(); - cx_a.foreground().run_until_parked(); - client_b - .user_store - .update(*cx_b, |store, cx| { - store.respond_to_contact_request(client_a.user_id().unwrap(), true, cx) - }) - .await - .unwrap(); - } - } - } - - async fn build_app_state(test_db: &TestDb) -> Arc { - Arc::new(AppState { - db: test_db.db().clone(), - api_token: Default::default(), - invite_link_prefix: Default::default(), - }) - } - - async fn state<'a>(&'a self) -> RwLockReadGuard<'a, Store> { - self.server.store.read().await - } - - async fn condition(&mut self, mut predicate: F) - where - F: FnMut(&Store) -> bool, - { - assert!( - self.foreground.parking_forbidden(), - "you must call forbid_parking to use server conditions so we don't block indefinitely" - ); - while !(predicate)(&*self.server.store.read().await) { - self.foreground.start_waiting(); - self.notifications.next().await; - self.foreground.finish_waiting(); - } - } - } - - impl Deref for TestServer { - type Target = Server; - - fn deref(&self) -> &Self::Target { - &self.server - } - } - - impl Drop for TestServer { - fn drop(&mut self) { - self.peer.reset(); - } - } - - struct TestClient { - client: Arc, - username: String, - pub peer_id: PeerId, - pub user_store: ModelHandle, - language_registry: Arc, - project: Option>, - buffers: HashSet>, - } - - impl Deref for TestClient { - type Target = Arc; - - fn deref(&self) -> &Self::Target { - &self.client - } - } - - struct ContactsSummary { - pub current: Vec, - pub outgoing_requests: Vec, - pub incoming_requests: Vec, - } - - impl TestClient { - pub fn current_user_id(&self, cx: &TestAppContext) -> UserId { - UserId::from_proto( - self.user_store - .read_with(cx, |user_store, _| user_store.current_user().unwrap().id), - ) - } - - async fn wait_for_current_user(&self, cx: &TestAppContext) { - let mut authed_user = self - .user_store - .read_with(cx, |user_store, _| user_store.watch_current_user()); - while authed_user.next().await.unwrap().is_none() {} - } - - async fn clear_contacts(&self, cx: &mut TestAppContext) { - self.user_store - .update(cx, |store, _| store.clear_contacts()) - .await; - } - - fn summarize_contacts(&self, cx: &TestAppContext) -> ContactsSummary { - self.user_store.read_with(cx, |store, _| ContactsSummary { - current: store - .contacts() - .iter() - .map(|contact| contact.user.github_login.clone()) - .collect(), - outgoing_requests: store - .outgoing_contact_requests() - .iter() - .map(|user| user.github_login.clone()) - .collect(), - incoming_requests: store - .incoming_contact_requests() - .iter() - .map(|user| user.github_login.clone()) - .collect(), - }) - } - - async fn build_local_project( - &mut self, - fs: Arc, - root_path: impl AsRef, - cx: &mut TestAppContext, - ) -> (ModelHandle, WorktreeId) { - let project = cx.update(|cx| { - Project::local( - self.client.clone(), - self.user_store.clone(), - self.language_registry.clone(), - fs, - cx, - ) - }); - self.project = Some(project.clone()); - let (worktree, _) = project - .update(cx, |p, cx| { - p.find_or_create_local_worktree(root_path, true, cx) - }) - .await - .unwrap(); - worktree - .read_with(cx, |tree, _| tree.as_local().unwrap().scan_complete()) - .await; - project - .update(cx, |project, _| project.next_remote_id()) - .await; - (project, worktree.read_with(cx, |tree, _| tree.id())) - } - - async fn build_remote_project( - &mut self, - host_project: &ModelHandle, - host_cx: &mut TestAppContext, - guest_cx: &mut TestAppContext, - ) -> ModelHandle { - let host_project_id = host_project - .read_with(host_cx, |project, _| project.next_remote_id()) - .await; - let guest_user_id = self.user_id().unwrap(); - let languages = - host_project.read_with(host_cx, |project, _| project.languages().clone()); - let project_b = guest_cx.spawn(|mut cx| { - let user_store = self.user_store.clone(); - let guest_client = self.client.clone(); - async move { - Project::remote( - host_project_id, - guest_client, - user_store.clone(), - languages, - FakeFs::new(cx.background()), - &mut cx, - ) - .await - .unwrap() - } - }); - host_cx.foreground().run_until_parked(); - host_project.update(host_cx, |project, cx| { - project.respond_to_join_request(guest_user_id, true, cx) - }); - let project = project_b.await; - self.project = Some(project.clone()); - project - } - - fn build_workspace( - &self, - project: &ModelHandle, - cx: &mut TestAppContext, - ) -> ViewHandle { - let (window_id, _) = cx.add_window(|_| EmptyView); - cx.add_view(window_id, |cx| Workspace::new(project.clone(), cx)) - } - - async fn simulate_host( - mut self, - project: ModelHandle, - op_start_signal: futures::channel::mpsc::UnboundedReceiver<()>, - rng: Arc>, - mut cx: TestAppContext, - ) -> (Self, TestAppContext, Option) { - async fn simulate_host_internal( - client: &mut TestClient, - project: ModelHandle, - mut op_start_signal: futures::channel::mpsc::UnboundedReceiver<()>, - rng: Arc>, - cx: &mut TestAppContext, - ) -> anyhow::Result<()> { - let fs = project.read_with(cx, |project, _| project.fs().clone()); - - cx.update(|cx| { - cx.subscribe(&project, move |project, event, cx| { - if let project::Event::ContactRequestedJoin(user) = event { - log::info!("Host: accepting join request from {}", user.github_login); - project.update(cx, |project, cx| { - project.respond_to_join_request(user.id, true, cx) - }); - } - }) - .detach(); - }); - - while op_start_signal.next().await.is_some() { - let distribution = rng.lock().gen_range::(0..100); - let files = fs.as_fake().files().await; - match distribution { - 0..=19 if !files.is_empty() => { - let path = files.choose(&mut *rng.lock()).unwrap(); - let mut path = path.as_path(); - while let Some(parent_path) = path.parent() { - path = parent_path; - if rng.lock().gen() { - break; - } - } - - log::info!("Host: find/create local worktree {:?}", path); - let find_or_create_worktree = project.update(cx, |project, cx| { - project.find_or_create_local_worktree(path, true, cx) - }); - if rng.lock().gen() { - cx.background().spawn(find_or_create_worktree).detach(); - } else { - find_or_create_worktree.await?; - } - } - 20..=79 if !files.is_empty() => { - let buffer = if client.buffers.is_empty() || rng.lock().gen() { - let file = files.choose(&mut *rng.lock()).unwrap(); - let (worktree, path) = project - .update(cx, |project, cx| { - project.find_or_create_local_worktree( - file.clone(), - true, - cx, - ) - }) - .await?; - let project_path = - worktree.read_with(cx, |worktree, _| (worktree.id(), path)); - log::info!( - "Host: opening path {:?}, worktree {}, relative_path {:?}", - file, - project_path.0, - project_path.1 - ); - let buffer = project - .update(cx, |project, cx| project.open_buffer(project_path, cx)) - .await - .unwrap(); - client.buffers.insert(buffer.clone()); - buffer - } else { - client - .buffers - .iter() - .choose(&mut *rng.lock()) - .unwrap() - .clone() - }; - - if rng.lock().gen_bool(0.1) { - cx.update(|cx| { - log::info!( - "Host: dropping buffer {:?}", - buffer.read(cx).file().unwrap().full_path(cx) - ); - client.buffers.remove(&buffer); - drop(buffer); - }); - } else { - buffer.update(cx, |buffer, cx| { - log::info!( - "Host: updating buffer {:?} ({})", - buffer.file().unwrap().full_path(cx), - buffer.remote_id() - ); - - if rng.lock().gen_bool(0.7) { - buffer.randomly_edit(&mut *rng.lock(), 5, cx); - } else { - buffer.randomly_undo_redo(&mut *rng.lock(), cx); - } - }); - } - } - _ => loop { - let path_component_count = rng.lock().gen_range::(1..=5); - let mut path = PathBuf::new(); - path.push("/"); - for _ in 0..path_component_count { - let letter = rng.lock().gen_range(b'a'..=b'z'); - path.push(std::str::from_utf8(&[letter]).unwrap()); - } - path.set_extension("rs"); - let parent_path = path.parent().unwrap(); - - log::info!("Host: creating file {:?}", path,); - - if fs.create_dir(&parent_path).await.is_ok() - && fs.create_file(&path, Default::default()).await.is_ok() - { - break; - } else { - log::info!("Host: cannot create file"); - } - }, - } - - cx.background().simulate_random_delay().await; - } - - Ok(()) - } - - let result = - simulate_host_internal(&mut self, project.clone(), op_start_signal, rng, &mut cx) - .await; - log::info!("Host done"); - self.project = Some(project); - (self, cx, result.err()) - } - - pub async fn simulate_guest( - mut self, - guest_username: String, - project: ModelHandle, - op_start_signal: futures::channel::mpsc::UnboundedReceiver<()>, - rng: Arc>, - mut cx: TestAppContext, - ) -> (Self, TestAppContext, Option) { - async fn simulate_guest_internal( - client: &mut TestClient, - guest_username: &str, - project: ModelHandle, - mut op_start_signal: futures::channel::mpsc::UnboundedReceiver<()>, - rng: Arc>, - cx: &mut TestAppContext, - ) -> anyhow::Result<()> { - while op_start_signal.next().await.is_some() { - let buffer = if client.buffers.is_empty() || rng.lock().gen() { - let worktree = if let Some(worktree) = - project.read_with(cx, |project, cx| { - project - .worktrees(&cx) - .filter(|worktree| { - let worktree = worktree.read(cx); - worktree.is_visible() - && worktree.entries(false).any(|e| e.is_file()) - }) - .choose(&mut *rng.lock()) - }) { - worktree - } else { - cx.background().simulate_random_delay().await; - continue; - }; - - let (worktree_root_name, project_path) = - worktree.read_with(cx, |worktree, _| { - let entry = worktree - .entries(false) - .filter(|e| e.is_file()) - .choose(&mut *rng.lock()) - .unwrap(); - ( - worktree.root_name().to_string(), - (worktree.id(), entry.path.clone()), - ) - }); - log::info!( - "{}: opening path {:?} in worktree {} ({})", - guest_username, - project_path.1, - project_path.0, - worktree_root_name, - ); - let buffer = project - .update(cx, |project, cx| { - project.open_buffer(project_path.clone(), cx) - }) - .await?; - log::info!( - "{}: opened path {:?} in worktree {} ({}) with buffer id {}", - guest_username, - project_path.1, - project_path.0, - worktree_root_name, - buffer.read_with(cx, |buffer, _| buffer.remote_id()) - ); - client.buffers.insert(buffer.clone()); - buffer - } else { - client - .buffers - .iter() - .choose(&mut *rng.lock()) - .unwrap() - .clone() - }; - - let choice = rng.lock().gen_range(0..100); - match choice { - 0..=9 => { - cx.update(|cx| { - log::info!( - "{}: dropping buffer {:?}", - guest_username, - buffer.read(cx).file().unwrap().full_path(cx) - ); - client.buffers.remove(&buffer); - drop(buffer); - }); - } - 10..=19 => { - let completions = project.update(cx, |project, cx| { - log::info!( - "{}: requesting completions for buffer {} ({:?})", - guest_username, - buffer.read(cx).remote_id(), - buffer.read(cx).file().unwrap().full_path(cx) - ); - let offset = rng.lock().gen_range(0..=buffer.read(cx).len()); - project.completions(&buffer, offset, cx) - }); - let completions = cx.background().spawn(async move { - completions - .await - .map_err(|err| anyhow!("completions request failed: {:?}", err)) - }); - if rng.lock().gen_bool(0.3) { - log::info!("{}: detaching completions request", guest_username); - cx.update(|cx| completions.detach_and_log_err(cx)); - } else { - completions.await?; - } - } - 20..=29 => { - let code_actions = project.update(cx, |project, cx| { - log::info!( - "{}: requesting code actions for buffer {} ({:?})", - guest_username, - buffer.read(cx).remote_id(), - buffer.read(cx).file().unwrap().full_path(cx) - ); - let range = buffer.read(cx).random_byte_range(0, &mut *rng.lock()); - project.code_actions(&buffer, range, cx) - }); - let code_actions = cx.background().spawn(async move { - code_actions.await.map_err(|err| { - anyhow!("code actions request failed: {:?}", err) - }) - }); - if rng.lock().gen_bool(0.3) { - log::info!("{}: detaching code actions request", guest_username); - cx.update(|cx| code_actions.detach_and_log_err(cx)); - } else { - code_actions.await?; - } - } - 30..=39 if buffer.read_with(cx, |buffer, _| buffer.is_dirty()) => { - let (requested_version, save) = buffer.update(cx, |buffer, cx| { - log::info!( - "{}: saving buffer {} ({:?})", - guest_username, - buffer.remote_id(), - buffer.file().unwrap().full_path(cx) - ); - (buffer.version(), buffer.save(cx)) - }); - let save = cx.background().spawn(async move { - let (saved_version, _) = save - .await - .map_err(|err| anyhow!("save request failed: {:?}", err))?; - assert!(saved_version.observed_all(&requested_version)); - Ok::<_, anyhow::Error>(()) - }); - if rng.lock().gen_bool(0.3) { - log::info!("{}: detaching save request", guest_username); - cx.update(|cx| save.detach_and_log_err(cx)); - } else { - save.await?; - } - } - 40..=44 => { - let prepare_rename = project.update(cx, |project, cx| { - log::info!( - "{}: preparing rename for buffer {} ({:?})", - guest_username, - buffer.read(cx).remote_id(), - buffer.read(cx).file().unwrap().full_path(cx) - ); - let offset = rng.lock().gen_range(0..=buffer.read(cx).len()); - project.prepare_rename(buffer, offset, cx) - }); - let prepare_rename = cx.background().spawn(async move { - prepare_rename.await.map_err(|err| { - anyhow!("prepare rename request failed: {:?}", err) - }) - }); - if rng.lock().gen_bool(0.3) { - log::info!("{}: detaching prepare rename request", guest_username); - cx.update(|cx| prepare_rename.detach_and_log_err(cx)); - } else { - prepare_rename.await?; - } - } - 45..=49 => { - let definitions = project.update(cx, |project, cx| { - log::info!( - "{}: requesting definitions for buffer {} ({:?})", - guest_username, - buffer.read(cx).remote_id(), - buffer.read(cx).file().unwrap().full_path(cx) - ); - let offset = rng.lock().gen_range(0..=buffer.read(cx).len()); - project.definition(&buffer, offset, cx) - }); - let definitions = cx.background().spawn(async move { - definitions - .await - .map_err(|err| anyhow!("definitions request failed: {:?}", err)) - }); - if rng.lock().gen_bool(0.3) { - log::info!("{}: detaching definitions request", guest_username); - cx.update(|cx| definitions.detach_and_log_err(cx)); - } else { - client - .buffers - .extend(definitions.await?.into_iter().map(|loc| loc.buffer)); - } - } - 50..=54 => { - let highlights = project.update(cx, |project, cx| { - log::info!( - "{}: requesting highlights for buffer {} ({:?})", - guest_username, - buffer.read(cx).remote_id(), - buffer.read(cx).file().unwrap().full_path(cx) - ); - let offset = rng.lock().gen_range(0..=buffer.read(cx).len()); - project.document_highlights(&buffer, offset, cx) - }); - let highlights = cx.background().spawn(async move { - highlights - .await - .map_err(|err| anyhow!("highlights request failed: {:?}", err)) - }); - if rng.lock().gen_bool(0.3) { - log::info!("{}: detaching highlights request", guest_username); - cx.update(|cx| highlights.detach_and_log_err(cx)); - } else { - highlights.await?; - } - } - 55..=59 => { - let search = project.update(cx, |project, cx| { - let query = rng.lock().gen_range('a'..='z'); - log::info!("{}: project-wide search {:?}", guest_username, query); - project.search(SearchQuery::text(query, false, false), cx) - }); - let search = cx.background().spawn(async move { - search - .await - .map_err(|err| anyhow!("search request failed: {:?}", err)) - }); - if rng.lock().gen_bool(0.3) { - log::info!("{}: detaching search request", guest_username); - cx.update(|cx| search.detach_and_log_err(cx)); - } else { - client.buffers.extend(search.await?.into_keys()); - } - } - 60..=69 => { - let worktree = project - .read_with(cx, |project, cx| { - project - .worktrees(&cx) - .filter(|worktree| { - let worktree = worktree.read(cx); - worktree.is_visible() - && worktree.entries(false).any(|e| e.is_file()) - && worktree - .root_entry() - .map_or(false, |e| e.is_dir()) - }) - .choose(&mut *rng.lock()) - }) - .unwrap(); - let (worktree_id, worktree_root_name) = worktree - .read_with(cx, |worktree, _| { - (worktree.id(), worktree.root_name().to_string()) - }); - - let mut new_name = String::new(); - for _ in 0..10 { - let letter = rng.lock().gen_range('a'..='z'); - new_name.push(letter); - } - let mut new_path = PathBuf::new(); - new_path.push(new_name); - new_path.set_extension("rs"); - log::info!( - "{}: creating {:?} in worktree {} ({})", - guest_username, - new_path, - worktree_id, - worktree_root_name, - ); - project - .update(cx, |project, cx| { - project.create_entry((worktree_id, new_path), false, cx) - }) - .unwrap() - .await?; - } - _ => { - buffer.update(cx, |buffer, cx| { - log::info!( - "{}: updating buffer {} ({:?})", - guest_username, - buffer.remote_id(), - buffer.file().unwrap().full_path(cx) - ); - if rng.lock().gen_bool(0.7) { - buffer.randomly_edit(&mut *rng.lock(), 5, cx); - } else { - buffer.randomly_undo_redo(&mut *rng.lock(), cx); - } - }); - } - } - cx.background().simulate_random_delay().await; - } - Ok(()) - } - - let result = simulate_guest_internal( - &mut self, - &guest_username, - project.clone(), - op_start_signal, - rng, - &mut cx, - ) - .await; - log::info!("{}: done", guest_username); - - self.project = Some(project); - (self, cx, result.err()) - } - } - - impl Drop for TestClient { - fn drop(&mut self) { - self.client.tear_down(); - } - } - - impl Executor for Arc { - type Sleep = gpui::executor::Timer; - - fn spawn_detached>(&self, future: F) { - self.spawn(future).detach(); - } - - fn sleep(&self, duration: Duration) -> Self::Sleep { - self.as_ref().timer(duration) - } - } - - fn channel_messages(channel: &Channel) -> Vec<(String, String, bool)> { - channel - .messages() - .cursor::<()>() - .map(|m| { - ( - m.sender.github_login.clone(), - m.body.clone(), - m.is_pending(), - ) - }) - .collect() - } - - struct EmptyView; - - impl gpui::Entity for EmptyView { - type Event = (); - } - - impl gpui::View for EmptyView { - fn ui_name() -> &'static str { - "empty view" - } - - fn render(&mut self, _: &mut gpui::RenderContext) -> gpui::ElementBox { - gpui::Element::boxed(gpui::elements::Empty::new()) - } - } -} diff --git a/crates/editor/src/context_menu.rs b/crates/editor/src/context_menu.rs deleted file mode 100644 index b1c4a1b51cbac521c0a3879501398ef1eb899b88..0000000000000000000000000000000000000000 --- a/crates/editor/src/context_menu.rs +++ /dev/null @@ -1,272 +0,0 @@ -pub enum ContextMenu { - Completions(CompletionsMenu), - CodeActions(CodeActionsMenu), -} - -impl ContextMenu { - pub fn select_prev(&mut self, cx: &mut ViewContext) -> bool { - if self.visible() { - match self { - ContextMenu::Completions(menu) => menu.select_prev(cx), - ContextMenu::CodeActions(menu) => menu.select_prev(cx), - } - true - } else { - false - } - } - - pub fn select_next(&mut self, cx: &mut ViewContext) -> bool { - if self.visible() { - match self { - ContextMenu::Completions(menu) => menu.select_next(cx), - ContextMenu::CodeActions(menu) => menu.select_next(cx), - } - true - } else { - false - } - } - - pub fn visible(&self) -> bool { - match self { - ContextMenu::Completions(menu) => menu.visible(), - ContextMenu::CodeActions(menu) => menu.visible(), - } - } - - pub fn render( - &self, - cursor_position: DisplayPoint, - style: EditorStyle, - cx: &AppContext, - ) -> (DisplayPoint, ElementBox) { - match self { - ContextMenu::Completions(menu) => (cursor_position, menu.render(style, cx)), - ContextMenu::CodeActions(menu) => menu.render(cursor_position, style), - } - } -} - -struct CompletionsMenu { - id: CompletionId, - initial_position: Anchor, - buffer: ModelHandle, - completions: Arc<[Completion]>, - match_candidates: Vec, - matches: Arc<[StringMatch]>, - selected_item: usize, - list: UniformListState, -} - -impl CompletionsMenu { - fn select_prev(&mut self, cx: &mut ViewContext) { - if self.selected_item > 0 { - self.selected_item -= 1; - self.list.scroll_to(ScrollTarget::Show(self.selected_item)); - } - cx.notify(); - } - - fn select_next(&mut self, cx: &mut ViewContext) { - if self.selected_item + 1 < self.matches.len() { - self.selected_item += 1; - self.list.scroll_to(ScrollTarget::Show(self.selected_item)); - } - cx.notify(); - } - - fn visible(&self) -> bool { - !self.matches.is_empty() - } - - fn render(&self, style: EditorStyle, _: &AppContext) -> ElementBox { - enum CompletionTag {} - - let completions = self.completions.clone(); - let matches = self.matches.clone(); - let selected_item = self.selected_item; - let container_style = style.autocomplete.container; - UniformList::new(self.list.clone(), matches.len(), move |range, items, cx| { - let start_ix = range.start; - for (ix, mat) in matches[range].iter().enumerate() { - let completion = &completions[mat.candidate_id]; - let item_ix = start_ix + ix; - items.push( - MouseEventHandler::new::( - mat.candidate_id, - cx, - |state, _| { - let item_style = if item_ix == selected_item { - style.autocomplete.selected_item - } else if state.hovered { - style.autocomplete.hovered_item - } else { - style.autocomplete.item - }; - - Text::new(completion.label.text.clone(), style.text.clone()) - .with_soft_wrap(false) - .with_highlights(combine_syntax_and_fuzzy_match_highlights( - &completion.label.text, - style.text.color.into(), - styled_runs_for_code_label(&completion.label, &style.syntax), - &mat.positions, - )) - .contained() - .with_style(item_style) - .boxed() - }, - ) - .with_cursor_style(CursorStyle::PointingHand) - .on_mouse_down(move |cx| { - cx.dispatch_action(ConfirmCompletion(Some(item_ix))); - }) - .boxed(), - ); - } - }) - .with_width_from_item( - self.matches - .iter() - .enumerate() - .max_by_key(|(_, mat)| { - self.completions[mat.candidate_id] - .label - .text - .chars() - .count() - }) - .map(|(ix, _)| ix), - ) - .contained() - .with_style(container_style) - .boxed() - } - - pub async fn filter(&mut self, query: Option<&str>, executor: Arc) { - let mut matches = if let Some(query) = query { - fuzzy::match_strings( - &self.match_candidates, - query, - false, - 100, - &Default::default(), - executor, - ) - .await - } else { - self.match_candidates - .iter() - .enumerate() - .map(|(candidate_id, candidate)| StringMatch { - candidate_id, - score: Default::default(), - positions: Default::default(), - string: candidate.string.clone(), - }) - .collect() - }; - matches.sort_unstable_by_key(|mat| { - ( - Reverse(OrderedFloat(mat.score)), - self.completions[mat.candidate_id].sort_key(), - ) - }); - - for mat in &mut matches { - let filter_start = self.completions[mat.candidate_id].label.filter_range.start; - for position in &mut mat.positions { - *position += filter_start; - } - } - - self.matches = matches.into(); - } -} - -#[derive(Clone)] -struct CodeActionsMenu { - actions: Arc<[CodeAction]>, - buffer: ModelHandle, - selected_item: usize, - list: UniformListState, - deployed_from_indicator: bool, -} - -impl CodeActionsMenu { - fn select_prev(&mut self, cx: &mut ViewContext) { - if self.selected_item > 0 { - self.selected_item -= 1; - cx.notify() - } - } - - fn select_next(&mut self, cx: &mut ViewContext) { - if self.selected_item + 1 < self.actions.len() { - self.selected_item += 1; - cx.notify() - } - } - - fn visible(&self) -> bool { - !self.actions.is_empty() - } - - fn render( - &self, - mut cursor_position: DisplayPoint, - style: EditorStyle, - ) -> (DisplayPoint, ElementBox) { - enum ActionTag {} - - let container_style = style.autocomplete.container; - let actions = self.actions.clone(); - let selected_item = self.selected_item; - let element = - UniformList::new(self.list.clone(), actions.len(), move |range, items, cx| { - let start_ix = range.start; - for (ix, action) in actions[range].iter().enumerate() { - let item_ix = start_ix + ix; - items.push( - MouseEventHandler::new::(item_ix, cx, |state, _| { - let item_style = if item_ix == selected_item { - style.autocomplete.selected_item - } else if state.hovered { - style.autocomplete.hovered_item - } else { - style.autocomplete.item - }; - - Text::new(action.lsp_action.title.clone(), style.text.clone()) - .with_soft_wrap(false) - .contained() - .with_style(item_style) - .boxed() - }) - .with_cursor_style(CursorStyle::PointingHand) - .on_mouse_down(move |cx| { - cx.dispatch_action(ConfirmCodeAction(Some(item_ix))); - }) - .boxed(), - ); - } - }) - .with_width_from_item( - self.actions - .iter() - .enumerate() - .max_by_key(|(_, action)| action.lsp_action.title.chars().count()) - .map(|(ix, _)| ix), - ) - .contained() - .with_style(container_style) - .boxed(); - - if self.deployed_from_indicator { - *cursor_position.column_mut() = 0; - } - - (cursor_position, element) - } -} diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 3de44e031537a0627606faaecf1c7ceb2615c125..4378db540700dfd142551347832e10668b6f11e7 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -279,6 +279,23 @@ impl DisplaySnapshot { } } + pub fn expand_to_line(&self, range: Range) -> Range { + let mut new_start = self.prev_line_boundary(range.start).0; + let mut new_end = self.next_line_boundary(range.end).0; + + if new_start.row == range.start.row && new_end.row == range.end.row { + if new_end.row < self.buffer_snapshot.max_point().row { + new_end.row += 1; + new_end.column = 0; + } else if new_start.row > 0 { + new_start.row -= 1; + new_start.column = self.buffer_snapshot.line_len(new_start.row); + } + } + + new_start..new_end + } + fn point_to_display_point(&self, point: Point, bias: Bias) -> DisplayPoint { let fold_point = self.folds_snapshot.to_fold_point(point, bias); let tab_point = self.tabs_snapshot.to_tab_point(fold_point); diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 56e88add5401197d5c469099b3cfdfc8cd0e0773..d82a5baece26eed60bff60eefc043f517d9dd8df 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -3,10 +3,10 @@ mod element; pub mod items; pub mod movement; mod multi_buffer; -mod selections_collection; +pub mod selections_collection; -#[cfg(test)] -mod test; +#[cfg(any(test, feature = "test-support"))] +pub mod test; use aho_corasick::AhoCorasick; use anyhow::Result; @@ -850,9 +850,9 @@ struct ActiveDiagnosticGroup { } #[derive(Serialize, Deserialize)] -struct ClipboardSelection { - len: usize, - is_entire_line: bool, +pub struct ClipboardSelection { + pub len: usize, + pub is_entire_line: bool, } #[derive(Debug)] @@ -1038,6 +1038,10 @@ impl Editor { self.buffer.read(cx).replica_id() } + pub fn leader_replica_id(&self) -> Option { + self.leader_replica_id + } + pub fn buffer(&self) -> &ModelHandle { &self.buffer } @@ -1332,7 +1336,11 @@ impl Editor { ) { if self.focused && self.leader_replica_id.is_none() { self.buffer.update(cx, |buffer, cx| { - buffer.set_active_selections(&self.selections.disjoint_anchors(), cx) + buffer.set_active_selections( + &self.selections.disjoint_anchors(), + self.selections.line_mode, + cx, + ) }); } @@ -1406,12 +1414,14 @@ impl Editor { let old_cursor_position = self.selections.newest_anchor().head(); self.push_to_selection_history(); - let result = self.selections.change_with(cx, change); + let (changed, result) = self.selections.change_with(cx, change); - if let Some(autoscroll) = autoscroll { - self.request_autoscroll(autoscroll, cx); + if changed { + if let Some(autoscroll) = autoscroll { + self.request_autoscroll(autoscroll, cx); + } + self.selections_did_change(true, &old_cursor_position, cx); } - self.selections_did_change(true, &old_cursor_position, cx); result } @@ -1551,12 +1561,10 @@ impl Editor { } self.change_selections(Some(Autoscroll::Fit), cx, |s| { - if add { - if click_count > 1 { - s.delete(newest_selection.id); - } - } else { + if !add { s.clear_disjoint(); + } else if click_count > 1 { + s.delete(newest_selection.id) } s.set_pending_range(start..end, mode); @@ -1869,13 +1877,16 @@ impl Editor { pub fn insert(&mut self, text: &str, cx: &mut ViewContext) { let text: Arc = text.into(); self.transact(cx, |this, cx| { - let old_selections = this.selections.all::(cx); + let old_selections = this.selections.all_adjusted(cx); let selection_anchors = this.buffer.update(cx, |buffer, cx| { let anchors = { let snapshot = buffer.read(cx); old_selections .iter() - .map(|s| (s.id, s.goal, snapshot.anchor_after(s.end))) + .map(|s| { + let anchor = snapshot.anchor_after(s.end); + s.map(|_| anchor.clone()) + }) .collect::>() }; buffer.edit_with_autoindent( @@ -1887,25 +1898,8 @@ impl Editor { anchors }); - let selections = { - let snapshot = this.buffer.read(cx).read(cx); - selection_anchors - .into_iter() - .map(|(id, goal, position)| { - let position = position.to_offset(&snapshot); - Selection { - id, - start: position, - end: position, - goal, - reversed: false, - } - }) - .collect() - }; - this.change_selections(Some(Autoscroll::Fit), cx, |s| { - s.select(selections); + s.select_anchors(selection_anchors); }) }); } @@ -2758,28 +2752,31 @@ impl Editor { pub fn backspace(&mut self, _: &Backspace, cx: &mut ViewContext) { let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); let mut selections = self.selections.all::(cx); - for selection in &mut selections { - if selection.is_empty() { - let old_head = selection.head(); - let mut new_head = - movement::left(&display_map, old_head.to_display_point(&display_map)) - .to_point(&display_map); - if let Some((buffer, line_buffer_range)) = display_map - .buffer_snapshot - .buffer_line_for_row(old_head.row) - { - let indent_column = buffer.indent_column_for_line(line_buffer_range.start.row); - let language_name = buffer.language().map(|language| language.name()); - let indent = cx.global::().tab_size(language_name.as_deref()); - if old_head.column <= indent_column && old_head.column > 0 { - new_head = cmp::min( - new_head, - Point::new(old_head.row, ((old_head.column - 1) / indent) * indent), - ); + if !self.selections.line_mode { + for selection in &mut selections { + if selection.is_empty() { + let old_head = selection.head(); + let mut new_head = + movement::left(&display_map, old_head.to_display_point(&display_map)) + .to_point(&display_map); + if let Some((buffer, line_buffer_range)) = display_map + .buffer_snapshot + .buffer_line_for_row(old_head.row) + { + let indent_column = + buffer.indent_column_for_line(line_buffer_range.start.row); + let language_name = buffer.language().map(|language| language.name()); + let indent = cx.global::().tab_size(language_name.as_deref()); + if old_head.column <= indent_column && old_head.column > 0 { + new_head = cmp::min( + new_head, + Point::new(old_head.row, ((old_head.column - 1) / indent) * indent), + ); + } } - } - selection.set_head(new_head, SelectionGoal::None); + selection.set_head(new_head, SelectionGoal::None); + } } } @@ -2792,8 +2789,9 @@ impl Editor { pub fn delete(&mut self, _: &Delete, cx: &mut ViewContext) { self.transact(cx, |this, cx| { this.change_selections(Some(Autoscroll::Fit), cx, |s| { + let line_mode = s.line_mode; s.move_with(|map, selection| { - if selection.is_empty() { + if selection.is_empty() && !line_mode { let cursor = movement::right(map, selection.head()); selection.set_head(cursor, SelectionGoal::None); } @@ -2816,7 +2814,7 @@ impl Editor { return; } - let mut selections = self.selections.all::(cx); + let mut selections = self.selections.all_adjusted(cx); if selections.iter().all(|s| s.is_empty()) { self.transact(cx, |this, cx| { this.buffer.update(cx, |buffer, cx| { @@ -3302,8 +3300,9 @@ impl Editor { self.transact(cx, |this, cx| { let edits = this.change_selections(Some(Autoscroll::Fit), cx, |s| { let mut edits: Vec<(Range, String)> = Default::default(); + let line_mode = s.line_mode; s.move_with(|display_map, selection| { - if !selection.is_empty() { + if !selection.is_empty() || line_mode { return; } @@ -3356,7 +3355,7 @@ impl Editor { { let max_point = buffer.max_point(); for selection in &mut selections { - let is_entire_line = selection.is_empty(); + let is_entire_line = selection.is_empty() || self.selections.line_mode; if is_entire_line { selection.start = Point::new(selection.start.row, 0); selection.end = cmp::min(max_point, Point::new(selection.end.row + 1, 0)); @@ -3387,16 +3386,17 @@ impl Editor { let selections = self.selections.all::(cx); let buffer = self.buffer.read(cx).read(cx); let mut text = String::new(); + let mut clipboard_selections = Vec::with_capacity(selections.len()); { let max_point = buffer.max_point(); for selection in selections.iter() { let mut start = selection.start; let mut end = selection.end; - let is_entire_line = selection.is_empty(); + let is_entire_line = selection.is_empty() || self.selections.line_mode; if is_entire_line { start = Point::new(start.row, 0); - end = cmp::min(max_point, Point::new(start.row + 1, 0)); + end = cmp::min(max_point, Point::new(end.row + 1, 0)); } let mut len = 0; for chunk in buffer.text_for_range(start..end) { @@ -3440,6 +3440,7 @@ impl Editor { let snapshot = buffer.read(cx); let mut start_offset = 0; let mut edits = Vec::new(); + let line_mode = this.selections.line_mode; for (ix, selection) in old_selections.iter().enumerate() { let to_insert; let entire_line; @@ -3457,12 +3458,12 @@ impl Editor { // clipboard text was written, then the entire line containing the // selection was copied. If this selection is also currently empty, // then paste the line before the current line of the buffer. - let range = if selection.is_empty() && entire_line { + let range = if selection.is_empty() && !line_mode && entire_line { let column = selection.start.to_point(&snapshot).column as usize; let line_start = selection.start - column; line_start..line_start } else { - selection.start..selection.end + selection.range() }; edits.push((range, to_insert)); @@ -3512,8 +3513,9 @@ impl Editor { pub fn move_left(&mut self, _: &MoveLeft, cx: &mut ViewContext) { self.change_selections(Some(Autoscroll::Fit), cx, |s| { + let line_mode = s.line_mode; s.move_with(|map, selection| { - let cursor = if selection.is_empty() { + let cursor = if selection.is_empty() && !line_mode { movement::left(map, selection.start) } else { selection.start @@ -3531,8 +3533,9 @@ impl Editor { pub fn move_right(&mut self, _: &MoveRight, cx: &mut ViewContext) { self.change_selections(Some(Autoscroll::Fit), cx, |s| { + let line_mode = s.line_mode; s.move_with(|map, selection| { - let cursor = if selection.is_empty() { + let cursor = if selection.is_empty() && !line_mode { movement::right(map, selection.end) } else { selection.end @@ -3565,8 +3568,9 @@ impl Editor { } self.change_selections(Some(Autoscroll::Fit), cx, |s| { + let line_mode = s.line_mode; s.move_with(|map, selection| { - if !selection.is_empty() { + if !selection.is_empty() && !line_mode { selection.goal = SelectionGoal::None; } let (cursor, goal) = movement::up(&map, selection.start, selection.goal, false); @@ -3596,8 +3600,9 @@ impl Editor { } self.change_selections(Some(Autoscroll::Fit), cx, |s| { + let line_mode = s.line_mode; s.move_with(|map, selection| { - if !selection.is_empty() { + if !selection.is_empty() && !line_mode { selection.goal = SelectionGoal::None; } let (cursor, goal) = movement::down(&map, selection.end, selection.goal, false); @@ -3679,8 +3684,9 @@ impl Editor { ) { self.transact(cx, |this, cx| { this.change_selections(Some(Autoscroll::Fit), cx, |s| { + let line_mode = s.line_mode; s.move_with(|map, selection| { - if selection.is_empty() { + if selection.is_empty() && !line_mode { let cursor = movement::previous_word_start(map, selection.head()); selection.set_head(cursor, SelectionGoal::None); } @@ -3697,8 +3703,9 @@ impl Editor { ) { self.transact(cx, |this, cx| { this.change_selections(Some(Autoscroll::Fit), cx, |s| { + let line_mode = s.line_mode; s.move_with(|map, selection| { - if selection.is_empty() { + if selection.is_empty() && !line_mode { let cursor = movement::previous_subword_start(map, selection.head()); selection.set_head(cursor, SelectionGoal::None); } @@ -3751,8 +3758,9 @@ impl Editor { pub fn delete_to_next_word_end(&mut self, _: &DeleteToNextWordEnd, cx: &mut ViewContext) { self.transact(cx, |this, cx| { this.change_selections(Some(Autoscroll::Fit), cx, |s| { + let line_mode = s.line_mode; s.move_with(|map, selection| { - if selection.is_empty() { + if selection.is_empty() && !line_mode { let cursor = movement::next_word_end(map, selection.head()); selection.set_head(cursor, SelectionGoal::None); } @@ -4698,6 +4706,7 @@ impl Editor { // Position the selection in the rename editor so that it matches the current selection. this.show_local_selections = false; let rename_editor = cx.add_view(|cx| { + println!("Rename editor created."); let mut editor = Editor::single_line(None, cx); if let Some(old_highlight_id) = old_highlight_id { editor.override_text_style = @@ -5612,7 +5621,11 @@ impl View for Editor { self.buffer.update(cx, |buffer, cx| { buffer.finalize_last_transaction(cx); if self.leader_replica_id.is_none() { - buffer.set_active_selections(&self.selections.disjoint_anchors(), cx); + buffer.set_active_selections( + &self.selections.disjoint_anchors(), + self.selections.line_mode, + cx, + ); } }); } @@ -6033,7 +6046,9 @@ pub fn styled_runs_for_code_label<'a>( #[cfg(test)] mod tests { - use crate::test::{assert_text_with_selections, select_ranges}; + use crate::test::{ + assert_text_with_selections, build_editor, select_ranges, EditorTestContext, + }; use super::*; use gpui::{ @@ -7305,117 +7320,62 @@ mod tests { } #[gpui::test] - fn test_indent_outdent(cx: &mut gpui::MutableAppContext) { - cx.set_global(Settings::test(cx)); - let buffer = MultiBuffer::build_simple( - indoc! {" - one two - three - four"}, - cx, - ); - let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer.clone(), cx)); + async fn test_indent_outdent(cx: &mut gpui::TestAppContext) { + let mut cx = EditorTestContext::new(cx).await; - view.update(cx, |view, cx| { - // two selections on the same line - select_ranges( - view, - indoc! {" - [one] [two] - three - four"}, - cx, - ); - - // indent from mid-tabstop to full tabstop - view.tab(&Tab, cx); - assert_text_with_selections( - view, - indoc! {" - [one] [two] - three - four"}, - cx, - ); - - // outdent from 1 tabstop to 0 tabstops - view.tab_prev(&TabPrev, cx); - assert_text_with_selections( - view, - indoc! {" - [one] [two] - three - four"}, - cx, - ); - - // select across line ending - select_ranges( - view, - indoc! {" - one two - t[hree - ] four"}, - cx, - ); - - // indent and outdent affect only the preceding line - view.tab(&Tab, cx); - assert_text_with_selections( - view, - indoc! {" - one two - t[hree - ] four"}, - cx, - ); - view.tab_prev(&TabPrev, cx); - assert_text_with_selections( - view, - indoc! {" - one two - t[hree - ] four"}, - cx, - ); - - // Ensure that indenting/outdenting works when the cursor is at column 0. - select_ranges( - view, - indoc! {" - one two - []three - four"}, - cx, - ); - view.tab(&Tab, cx); - assert_text_with_selections( - view, - indoc! {" - one two - []three - four"}, - cx, - ); + cx.set_state(indoc! {" + [one} [two} + three + four"}); + cx.update_editor(|e, cx| e.tab(&Tab, cx)); + cx.assert_editor_state(indoc! {" + [one} [two} + three + four"}); - select_ranges( - view, - indoc! {" - one two - [] three - four"}, - cx, - ); - view.tab_prev(&TabPrev, cx); - assert_text_with_selections( - view, - indoc! {" - one two - []three - four"}, - cx, - ); - }); + cx.update_editor(|e, cx| e.tab_prev(&TabPrev, cx)); + cx.assert_editor_state(indoc! {" + [one} [two} + three + four"}); + + // select across line ending + cx.set_state(indoc! {" + one two + t[hree + } four"}); + cx.update_editor(|e, cx| e.tab(&Tab, cx)); + cx.assert_editor_state(indoc! {" + one two + t[hree + } four"}); + + cx.update_editor(|e, cx| e.tab_prev(&TabPrev, cx)); + cx.assert_editor_state(indoc! {" + one two + t[hree + } four"}); + + // Ensure that indenting/outdenting works when the cursor is at column 0. + cx.set_state(indoc! {" + one two + |three + four"}); + cx.update_editor(|e, cx| e.tab(&Tab, cx)); + cx.assert_editor_state(indoc! {" + one two + |three + four"}); + + cx.set_state(indoc! {" + one two + | three + four"}); + cx.update_editor(|e, cx| e.tab_prev(&TabPrev, cx)); + cx.assert_editor_state(indoc! {" + one two + |three + four"}); } #[gpui::test] @@ -7524,73 +7484,71 @@ mod tests { } #[gpui::test] - fn test_backspace(cx: &mut gpui::MutableAppContext) { - cx.set_global(Settings::test(cx)); - let (_, view) = cx.add_window(Default::default(), |cx| { - build_editor(MultiBuffer::build_simple("", cx), cx) - }); - - view.update(cx, |view, cx| { - view.set_text("one two three\nfour five six\nseven eight nine\nten\n", cx); - view.change_selections(None, cx, |s| { - s.select_display_ranges([ - // an empty selection - the preceding character is deleted - DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2), - // one character selected - it is deleted - DisplayPoint::new(1, 4)..DisplayPoint::new(1, 3), - // a line suffix selected - it is deleted - DisplayPoint::new(2, 6)..DisplayPoint::new(3, 0), - ]) - }); - view.backspace(&Backspace, cx); - assert_eq!(view.text(cx), "oe two three\nfou five six\nseven ten\n"); - - view.set_text(" one\n two\n three\n four", cx); - view.change_selections(None, cx, |s| { - s.select_display_ranges([ - // cursors at the the end of leading indent - last indent is deleted - DisplayPoint::new(0, 4)..DisplayPoint::new(0, 4), - DisplayPoint::new(1, 8)..DisplayPoint::new(1, 8), - // cursors inside leading indent - overlapping indent deletions are coalesced - DisplayPoint::new(2, 4)..DisplayPoint::new(2, 4), - DisplayPoint::new(2, 5)..DisplayPoint::new(2, 5), - DisplayPoint::new(2, 6)..DisplayPoint::new(2, 6), - // cursor at the beginning of a line - preceding newline is deleted - DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0), - // selection inside leading indent - only the selected character is deleted - DisplayPoint::new(3, 2)..DisplayPoint::new(3, 3), - ]) - }); - view.backspace(&Backspace, cx); - assert_eq!(view.text(cx), "one\n two\n three four"); - }); + async fn test_backspace(cx: &mut gpui::TestAppContext) { + let mut cx = EditorTestContext::new(cx).await; + // Basic backspace + cx.set_state(indoc! {" + on|e two three + fou[r} five six + seven {eight nine + ]ten"}); + cx.update_editor(|e, cx| e.backspace(&Backspace, cx)); + cx.assert_editor_state(indoc! {" + o|e two three + fou| five six + seven |ten"}); + + // Test backspace inside and around indents + cx.set_state(indoc! {" + zero + |one + |two + | | | three + | | four"}); + cx.update_editor(|e, cx| e.backspace(&Backspace, cx)); + cx.assert_editor_state(indoc! {" + zero + |one + |two + | three| four"}); + + // Test backspace with line_mode set to true + cx.update_editor(|e, _| e.selections.line_mode = true); + cx.set_state(indoc! {" + The |quick |brown + fox jumps over + the lazy dog + |The qu[ick b}rown"}); + cx.update_editor(|e, cx| e.backspace(&Backspace, cx)); + cx.assert_editor_state(indoc! {" + |fox jumps over + the lazy dog|"}); } #[gpui::test] - fn test_delete(cx: &mut gpui::MutableAppContext) { - cx.set_global(Settings::test(cx)); - let buffer = - MultiBuffer::build_simple("one two three\nfour five six\nseven eight nine\nten\n", cx); - let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer.clone(), cx)); - - view.update(cx, |view, cx| { - view.change_selections(None, cx, |s| { - s.select_display_ranges([ - // an empty selection - the following character is deleted - DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2), - // one character selected - it is deleted - DisplayPoint::new(1, 4)..DisplayPoint::new(1, 3), - // a line suffix selected - it is deleted - DisplayPoint::new(2, 6)..DisplayPoint::new(3, 0), - ]) - }); - view.delete(&Delete, cx); - }); - - assert_eq!( - buffer.read(cx).read(cx).text(), - "on two three\nfou five six\nseven ten\n" - ); + async fn test_delete(cx: &mut gpui::TestAppContext) { + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state(indoc! {" + on|e two three + fou[r} five six + seven {eight nine + ]ten"}); + cx.update_editor(|e, cx| e.delete(&Delete, cx)); + cx.assert_editor_state(indoc! {" + on| two three + fou| five six + seven |ten"}); + + // Test backspace with line_mode set to true + cx.update_editor(|e, _| e.selections.line_mode = true); + cx.set_state(indoc! {" + The |quick |brown + fox {jum]ps over + the lazy dog + |The qu[ick b}rown"}); + cx.update_editor(|e, cx| e.backspace(&Backspace, cx)); + cx.assert_editor_state("|the lazy dog|"); } #[gpui::test] @@ -7898,131 +7856,79 @@ mod tests { } #[gpui::test] - fn test_clipboard(cx: &mut gpui::MutableAppContext) { - cx.set_global(Settings::test(cx)); - let buffer = MultiBuffer::build_simple("one✅ two three four five six ", cx); - let view = cx - .add_window(Default::default(), |cx| build_editor(buffer.clone(), cx)) - .1; + async fn test_clipboard(cx: &mut gpui::TestAppContext) { + let mut cx = EditorTestContext::new(cx).await; - // Cut with three selections. Clipboard text is divided into three slices. - view.update(cx, |view, cx| { - view.change_selections(None, cx, |s| s.select_ranges(vec![0..7, 11..17, 22..27])); - view.cut(&Cut, cx); - assert_eq!(view.display_text(cx), "two four six "); - }); + cx.set_state("[one✅ }two [three }four [five }six "); + cx.update_editor(|e, cx| e.cut(&Cut, cx)); + cx.assert_editor_state("|two |four |six "); // Paste with three cursors. Each cursor pastes one slice of the clipboard text. - view.update(cx, |view, cx| { - view.change_selections(None, cx, |s| s.select_ranges(vec![4..4, 9..9, 13..13])); - view.paste(&Paste, cx); - assert_eq!(view.display_text(cx), "two one✅ four three six five "); - assert_eq!( - view.selections.display_ranges(cx), - &[ - DisplayPoint::new(0, 11)..DisplayPoint::new(0, 11), - DisplayPoint::new(0, 22)..DisplayPoint::new(0, 22), - DisplayPoint::new(0, 31)..DisplayPoint::new(0, 31) - ] - ); - }); + cx.set_state("two |four |six |"); + cx.update_editor(|e, cx| e.paste(&Paste, cx)); + cx.assert_editor_state("two one✅ |four three |six five |"); // Paste again but with only two cursors. Since the number of cursors doesn't // match the number of slices in the clipboard, the entire clipboard text // is pasted at each cursor. - view.update(cx, |view, cx| { - view.change_selections(None, cx, |s| s.select_ranges(vec![0..0, 31..31])); - view.handle_input(&Input("( ".into()), cx); - view.paste(&Paste, cx); - view.handle_input(&Input(") ".into()), cx); - assert_eq!( - view.display_text(cx), - "( one✅ \nthree \nfive ) two one✅ four three six five ( one✅ \nthree \nfive ) " - ); - }); - - view.update(cx, |view, cx| { - view.change_selections(None, cx, |s| s.select_ranges(vec![0..0])); - view.handle_input(&Input("123\n4567\n89\n".into()), cx); - assert_eq!( - view.display_text(cx), - "123\n4567\n89\n( one✅ \nthree \nfive ) two one✅ four three six five ( one✅ \nthree \nfive ) " - ); - }); + cx.set_state("|two one✅ four three six five |"); + cx.update_editor(|e, cx| { + e.handle_input(&Input("( ".into()), cx); + e.paste(&Paste, cx); + e.handle_input(&Input(") ".into()), cx); + }); + cx.assert_editor_state(indoc! {" + ( one✅ + three + five ) |two one✅ four three six five ( one✅ + three + five ) |"}); // Cut with three selections, one of which is full-line. - view.update(cx, |view, cx| { - view.change_selections(None, cx, |s| s.select_display_ranges( - [ - DisplayPoint::new(0, 1)..DisplayPoint::new(0, 2), - DisplayPoint::new(1, 1)..DisplayPoint::new(1, 1), - DisplayPoint::new(2, 0)..DisplayPoint::new(2, 1), - ], - )); - view.cut(&Cut, cx); - assert_eq!( - view.display_text(cx), - "13\n9\n( one✅ \nthree \nfive ) two one✅ four three six five ( one✅ \nthree \nfive ) " - ); - }); + cx.set_state(indoc! {" + 1[2}3 + 4|567 + [8}9"}); + cx.update_editor(|e, cx| e.cut(&Cut, cx)); + cx.assert_editor_state(indoc! {" + 1|3 + |9"}); // Paste with three selections, noticing how the copied selection that was full-line // gets inserted before the second cursor. - view.update(cx, |view, cx| { - view.change_selections(None, cx, |s| s.select_display_ranges( - [ - DisplayPoint::new(0, 1)..DisplayPoint::new(0, 1), - DisplayPoint::new(1, 1)..DisplayPoint::new(1, 1), - DisplayPoint::new(2, 2)..DisplayPoint::new(2, 3), - ], - )); - view.paste(&Paste, cx); - assert_eq!( - view.display_text(cx), - "123\n4567\n9\n( 8ne✅ \nthree \nfive ) two one✅ four three six five ( one✅ \nthree \nfive ) " - ); - assert_eq!( - view.selections.display_ranges(cx), - &[ - DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2), - DisplayPoint::new(2, 1)..DisplayPoint::new(2, 1), - DisplayPoint::new(3, 3)..DisplayPoint::new(3, 3), - ] - ); - }); + cx.set_state(indoc! {" + 1|3 + 9| + [o}ne"}); + cx.update_editor(|e, cx| e.paste(&Paste, cx)); + cx.assert_editor_state(indoc! {" + 12|3 + 4567 + 9| + 8|ne"}); // Copy with a single cursor only, which writes the whole line into the clipboard. - view.update(cx, |view, cx| { - view.change_selections(None, cx, |s| { - s.select_display_ranges([DisplayPoint::new(0, 1)..DisplayPoint::new(0, 1)]) - }); - view.copy(&Copy, cx); - }); + cx.set_state(indoc! {" + The quick brown + fox ju|mps over + the lazy dog"}); + cx.update_editor(|e, cx| e.copy(&Copy, cx)); + cx.assert_clipboard_content(Some("fox jumps over\n")); // Paste with three selections, noticing how the copied full-line selection is inserted // before the empty selections but replaces the selection that is non-empty. - view.update(cx, |view, cx| { - view.change_selections(None, cx, |s| s.select_display_ranges( - [ - DisplayPoint::new(0, 1)..DisplayPoint::new(0, 1), - DisplayPoint::new(1, 0)..DisplayPoint::new(1, 2), - DisplayPoint::new(2, 1)..DisplayPoint::new(2, 1), - ], - )); - view.paste(&Paste, cx); - assert_eq!( - view.display_text(cx), - "123\n123\n123\n67\n123\n9\n( 8ne✅ \nthree \nfive ) two one✅ four three six five ( one✅ \nthree \nfive ) " - ); - assert_eq!( - view.selections.display_ranges(cx), - &[ - DisplayPoint::new(1, 1)..DisplayPoint::new(1, 1), - DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0), - DisplayPoint::new(5, 1)..DisplayPoint::new(5, 1), - ] - ); - }); + cx.set_state(indoc! {" + T|he quick brown + [fo}x jumps over + t|he lazy dog"}); + cx.update_editor(|e, cx| e.paste(&Paste, cx)); + cx.assert_editor_state(indoc! {" + fox jumps over + T|he quick brown + fox jumps over + |x jumps over + fox jumps over + t|he lazy dog"}); } #[gpui::test] @@ -8761,8 +8667,10 @@ mod tests { fn assert(editor: &mut Editor, cx: &mut ViewContext, marked_text_ranges: &str) { let range_markers = ('<', '>'); let (expected_text, mut selection_ranges_lookup) = - marked_text_ranges_by(marked_text_ranges, vec![range_markers.clone()]); - let selection_ranges = selection_ranges_lookup.remove(&range_markers).unwrap(); + marked_text_ranges_by(marked_text_ranges, vec![range_markers.clone().into()]); + let selection_ranges = selection_ranges_lookup + .remove(&range_markers.into()) + .unwrap(); assert_eq!(editor.text(cx), expected_text); assert_eq!(editor.selections.ranges::(cx), selection_ranges); } @@ -9811,10 +9719,6 @@ mod tests { point..point } - fn build_editor(buffer: ModelHandle, cx: &mut ViewContext) -> Editor { - Editor::new(EditorMode::Full, buffer, None, None, None, cx) - } - fn assert_selection_ranges( marked_text: &str, selection_marker_pairs: Vec<(char, char)>, diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 28636e6b6f3b0f7749dc86ea9ede7f297eb6dcdc..d5a4f22eb563d6525c9c5594fb6c9af67de84ee8 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -3,7 +3,10 @@ use super::{ Anchor, DisplayPoint, Editor, EditorMode, EditorSnapshot, Input, Scroll, Select, SelectPhase, SoftWrap, ToPoint, MAX_LINE_LEN, }; -use crate::{display_map::TransformBlock, EditorStyle}; +use crate::{ + display_map::{DisplaySnapshot, TransformBlock}, + EditorStyle, +}; use clock::ReplicaId; use collections::{BTreeMap, HashMap}; use gpui::{ @@ -23,7 +26,7 @@ use gpui::{ WeakViewHandle, }; use json::json; -use language::{Bias, DiagnosticSeverity}; +use language::{Bias, DiagnosticSeverity, Selection}; use settings::Settings; use smallvec::SmallVec; use std::{ @@ -33,6 +36,35 @@ use std::{ ops::Range, }; +struct SelectionLayout { + head: DisplayPoint, + range: Range, +} + +impl SelectionLayout { + fn new( + selection: Selection, + line_mode: bool, + map: &DisplaySnapshot, + ) -> Self { + if line_mode { + let selection = selection.map(|p| p.to_point(&map.buffer_snapshot)); + let point_range = map.expand_to_line(selection.range()); + Self { + head: selection.head().to_display_point(map), + range: point_range.start.to_display_point(map) + ..point_range.end.to_display_point(map), + } + } else { + let selection = selection.map(|p| p.to_display_point(map)); + Self { + head: selection.head(), + range: selection.range(), + } + } + } +} + pub struct EditorElement { view: WeakViewHandle, style: EditorStyle, @@ -360,7 +392,7 @@ impl EditorElement { for selection in selections { self.paint_highlighted_range( - selection.start..selection.end, + selection.range.clone(), start_row, end_row, selection_style.selection, @@ -375,7 +407,7 @@ impl EditorElement { ); if view.show_local_cursors() || *replica_id != local_replica_id { - let cursor_position = selection.head(); + let cursor_position = selection.head; if (start_row..end_row).contains(&cursor_position.row()) { let cursor_row_layout = &layout.line_layouts[(cursor_position.row() - start_row) as usize]; @@ -922,7 +954,7 @@ impl Element for EditorElement { .anchor_before(DisplayPoint::new(end_row, 0).to_offset(&snapshot, Bias::Right)) }; - let mut selections = Vec::new(); + let mut selections: Vec<(ReplicaId, Vec)> = Vec::new(); let mut active_rows = BTreeMap::new(); let mut highlighted_rows = None; let mut highlighted_ranges = Vec::new(); @@ -938,7 +970,7 @@ impl Element for EditorElement { ); let mut remote_selections = HashMap::default(); - for (replica_id, selection) in display_map + for (replica_id, line_mode, selection) in display_map .buffer_snapshot .remote_selections_in_range(&(start_anchor.clone()..end_anchor.clone())) { @@ -946,17 +978,10 @@ impl Element for EditorElement { if Some(replica_id) == view.leader_replica_id { continue; } - remote_selections .entry(replica_id) .or_insert(Vec::new()) - .push(crate::Selection { - id: selection.id, - goal: selection.goal, - reversed: selection.reversed, - start: selection.start.to_display_point(&display_map), - end: selection.end.to_display_point(&display_map), - }); + .push(SelectionLayout::new(selection, line_mode, &display_map)); } selections.extend(remote_selections); @@ -985,12 +1010,8 @@ impl Element for EditorElement { local_replica_id, local_selections .into_iter() - .map(|selection| crate::Selection { - id: selection.id, - goal: selection.goal, - reversed: selection.reversed, - start: selection.start.to_display_point(&display_map), - end: selection.end.to_display_point(&display_map), + .map(|selection| { + SelectionLayout::new(selection, view.selections.line_mode, &display_map) }) .collect(), )); @@ -1243,7 +1264,7 @@ pub struct LayoutState { em_width: f32, em_advance: f32, highlighted_ranges: Vec<(Range, Color)>, - selections: Vec<(ReplicaId, Vec>)>, + selections: Vec<(ReplicaId, Vec)>, context_menu: Option<(DisplayPoint, ElementBox)>, code_actions_indicator: Option<(u32, ElementBox)>, } diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 0d8cbf1c6b47d66a19853fc3334b444b499fb895..47337aa9a2e6769b32b6486788a9e726e533d80b 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -103,7 +103,11 @@ impl FollowableItem for Editor { } else { self.buffer.update(cx, |buffer, cx| { if self.focused { - buffer.set_active_selections(&self.selections.disjoint_anchors(), cx); + buffer.set_active_selections( + &self.selections.disjoint_anchors(), + self.selections.line_mode, + cx, + ); } }); } diff --git a/crates/editor/src/multi_buffer.rs b/crates/editor/src/multi_buffer.rs index 0b8824be80f95f4b6bdd00610a7493ee29a01a58..6dd1b0685b675a96780e8017becfb5bcac240c3c 100644 --- a/crates/editor/src/multi_buffer.rs +++ b/crates/editor/src/multi_buffer.rs @@ -509,6 +509,7 @@ impl MultiBuffer { pub fn set_active_selections( &mut self, selections: &[Selection], + line_mode: bool, cx: &mut ModelContext, ) { let mut selections_by_buffer: HashMap>> = @@ -573,7 +574,7 @@ impl MultiBuffer { } Some(selection) })); - buffer.set_active_selections(merged_selections, cx); + buffer.set_active_selections(merged_selections, line_mode, cx); }); } } @@ -2397,7 +2398,7 @@ impl MultiBufferSnapshot { pub fn remote_selections_in_range<'a>( &'a self, range: &'a Range, - ) -> impl 'a + Iterator)> { + ) -> impl 'a + Iterator)> { let mut cursor = self.excerpts.cursor::>(); cursor.seek(&Some(&range.start.excerpt_id), Bias::Left, &()); cursor @@ -2414,7 +2415,7 @@ impl MultiBufferSnapshot { excerpt .buffer .remote_selections_in_range(query_range) - .flat_map(move |(replica_id, selections)| { + .flat_map(move |(replica_id, line_mode, selections)| { selections.map(move |selection| { let mut start = Anchor { buffer_id: Some(excerpt.buffer_id), @@ -2435,6 +2436,7 @@ impl MultiBufferSnapshot { ( replica_id, + line_mode, Selection { id: selection.id, start, diff --git a/crates/editor/src/selections_collection.rs b/crates/editor/src/selections_collection.rs index 07fa2cd9b501d4e5964ddf776be12bc22a7bb87f..7041062133666a645c3ff18212af2945c918937e 100644 --- a/crates/editor/src/selections_collection.rs +++ b/crates/editor/src/selections_collection.rs @@ -27,6 +27,7 @@ pub struct SelectionsCollection { display_map: ModelHandle, buffer: ModelHandle, pub next_selection_id: usize, + pub line_mode: bool, disjoint: Arc<[Selection]>, pending: Option, } @@ -37,6 +38,7 @@ impl SelectionsCollection { display_map, buffer, next_selection_id: 1, + line_mode: false, disjoint: Arc::from([]), pending: Some(PendingSelection { selection: Selection { @@ -126,6 +128,20 @@ impl SelectionsCollection { .collect() } + // Returns all of the selections, adjusted to take into account the selection line_mode + pub fn all_adjusted(&self, cx: &mut MutableAppContext) -> Vec> { + let mut selections = self.all::(cx); + if self.line_mode { + let map = self.display_map(cx); + for selection in &mut selections { + let new_range = map.expand_to_line(selection.range()); + selection.start = new_range.start; + selection.end = new_range.end; + } + } + selections + } + pub fn disjoint_in_range<'a, D>( &self, range: Range, @@ -273,9 +289,10 @@ impl SelectionsCollection { &mut self, cx: &mut MutableAppContext, change: impl FnOnce(&mut MutableSelectionsCollection) -> R, - ) -> R { + ) -> (bool, R) { let mut mutable_collection = MutableSelectionsCollection { collection: self, + selections_changed: false, cx, }; @@ -284,12 +301,13 @@ impl SelectionsCollection { !mutable_collection.disjoint.is_empty() || mutable_collection.pending.is_some(), "There must be at least one selection" ); - result + (mutable_collection.selections_changed, result) } } pub struct MutableSelectionsCollection<'a> { collection: &'a mut SelectionsCollection, + selections_changed: bool, cx: &'a mut MutableAppContext, } @@ -307,16 +325,26 @@ impl<'a> MutableSelectionsCollection<'a> { } pub fn delete(&mut self, selection_id: usize) { + let mut changed = false; self.collection.disjoint = self .disjoint .into_iter() - .filter(|selection| selection.id != selection_id) + .filter(|selection| { + let found = selection.id == selection_id; + changed |= found; + !found + }) .cloned() .collect(); + + self.selections_changed |= changed; } pub fn clear_pending(&mut self) { - self.collection.pending = None; + if self.collection.pending.is_some() { + self.collection.pending = None; + self.selections_changed = true; + } } pub fn set_pending_range(&mut self, range: Range, mode: SelectMode) { @@ -329,11 +357,13 @@ impl<'a> MutableSelectionsCollection<'a> { goal: SelectionGoal::None, }, mode, - }) + }); + self.selections_changed = true; } pub fn set_pending(&mut self, selection: Selection, mode: SelectMode) { self.collection.pending = Some(PendingSelection { selection, mode }); + self.selections_changed = true; } pub fn try_cancel(&mut self) -> bool { @@ -341,12 +371,14 @@ impl<'a> MutableSelectionsCollection<'a> { if self.disjoint.is_empty() { self.collection.disjoint = Arc::from([pending.selection]); } + self.selections_changed = true; return true; } let mut oldest = self.oldest_anchor().clone(); if self.count() > 1 { self.collection.disjoint = Arc::from([oldest]); + self.selections_changed = true; return true; } @@ -355,27 +387,13 @@ impl<'a> MutableSelectionsCollection<'a> { oldest.start = head.clone(); oldest.end = head; self.collection.disjoint = Arc::from([oldest]); + self.selections_changed = true; return true; } return false; } - pub fn reset_biases(&mut self) { - let buffer = self.buffer.read(self.cx).snapshot(self.cx); - self.collection.disjoint = self - .collection - .disjoint - .into_iter() - .cloned() - .map(|selection| reset_biases(selection, &buffer)) - .collect(); - - if let Some(pending) = self.collection.pending.as_mut() { - pending.selection = reset_biases(pending.selection.clone(), &buffer); - } - } - pub fn insert_range(&mut self, range: Range) where T: 'a + ToOffset + ToPoint + TextDimension + Ord + Sub + std::marker::Copy, @@ -437,6 +455,7 @@ impl<'a> MutableSelectionsCollection<'a> { })); self.collection.pending = None; + self.selections_changed = true; } pub fn select_anchors(&mut self, selections: Vec>) { @@ -535,18 +554,27 @@ impl<'a> MutableSelectionsCollection<'a> { &mut self, mut move_selection: impl FnMut(&DisplaySnapshot, &mut Selection), ) { + let mut changed = false; let display_map = self.display_map(); let selections = self .all::(self.cx) .into_iter() .map(|selection| { - let mut selection = selection.map(|point| point.to_display_point(&display_map)); - move_selection(&display_map, &mut selection); - selection.map(|display_point| display_point.to_point(&display_map)) + let mut moved_selection = + selection.map(|point| point.to_display_point(&display_map)); + move_selection(&display_map, &mut moved_selection); + let moved_selection = + moved_selection.map(|display_point| display_point.to_point(&display_map)); + if selection != moved_selection { + changed = true; + } + moved_selection }) .collect(); - self.select(selections) + if changed { + self.select(selections) + } } pub fn move_heads_with( @@ -670,6 +698,7 @@ impl<'a> MutableSelectionsCollection<'a> { pending.selection.end = end; } self.collection.pending = pending; + self.selections_changed = true; selections_with_lost_position } @@ -714,17 +743,3 @@ fn resolve>( ) -> Selection { selection.map(|p| p.summary::(&buffer)) } - -fn reset_biases( - mut selection: Selection, - buffer: &MultiBufferSnapshot, -) -> Selection { - let end_bias = if selection.end.to_offset(buffer) > selection.start.to_offset(buffer) { - Bias::Left - } else { - Bias::Right - }; - selection.start = buffer.anchor_after(selection.start); - selection.end = buffer.anchor_at(selection.end, end_bias); - selection -} diff --git a/crates/editor/src/test.rs b/crates/editor/src/test.rs index cb064be5459c3abd7af6ae9c9ed56aca06758acd..4c9ceed9aedeb334bb716cd505edcd015cb29337 100644 --- a/crates/editor/src/test.rs +++ b/crates/editor/src/test.rs @@ -1,9 +1,19 @@ -use gpui::ViewContext; -use util::test::{marked_text, marked_text_ranges}; +use std::ops::{Deref, DerefMut, Range}; + +use indoc::indoc; + +use collections::BTreeMap; +use gpui::{keymap::Keystroke, ModelHandle, ViewContext, ViewHandle}; +use language::Selection; +use settings::Settings; +use util::{ + set_eq, + test::{marked_text, marked_text_ranges, marked_text_ranges_by, SetEqError}, +}; use crate::{ display_map::{DisplayMap, DisplaySnapshot, ToDisplayPoint}, - DisplayPoint, Editor, MultiBuffer, + Autoscroll, DisplayPoint, Editor, EditorMode, MultiBuffer, }; #[cfg(test)] @@ -56,3 +66,301 @@ pub fn assert_text_with_selections( assert_eq!(editor.text(cx), unmarked_text); assert_eq!(editor.selections.ranges(cx), text_ranges); } + +pub(crate) fn build_editor( + buffer: ModelHandle, + cx: &mut ViewContext, +) -> Editor { + Editor::new(EditorMode::Full, buffer, None, None, None, cx) +} + +pub struct EditorTestContext<'a> { + pub cx: &'a mut gpui::TestAppContext, + pub window_id: usize, + pub editor: ViewHandle, +} + +impl<'a> EditorTestContext<'a> { + pub async fn new(cx: &'a mut gpui::TestAppContext) -> EditorTestContext<'a> { + let (window_id, editor) = cx.update(|cx| { + cx.set_global(Settings::test(cx)); + crate::init(cx); + + let (window_id, editor) = cx.add_window(Default::default(), |cx| { + build_editor(MultiBuffer::build_simple("", cx), cx) + }); + + editor.update(cx, |_, cx| cx.focus_self()); + + (window_id, editor) + }); + + Self { + cx, + window_id, + editor, + } + } + + pub fn update_editor(&mut self, update: F) -> T + where + F: FnOnce(&mut Editor, &mut ViewContext) -> T, + { + self.editor.update(self.cx, update) + } + + pub fn editor_text(&mut self) -> String { + self.editor + .update(self.cx, |editor, cx| editor.snapshot(cx).text()) + } + + pub fn simulate_keystroke(&mut self, keystroke_text: &str) { + let keystroke = Keystroke::parse(keystroke_text).unwrap(); + let input = if keystroke.modified() { + None + } else { + Some(keystroke.key.clone()) + }; + self.cx + .dispatch_keystroke(self.window_id, keystroke, input, false); + } + + pub fn simulate_keystrokes(&mut self, keystroke_texts: [&str; COUNT]) { + for keystroke_text in keystroke_texts.into_iter() { + self.simulate_keystroke(keystroke_text); + } + } + + // Sets the editor state via a marked string. + // `|` characters represent empty selections + // `[` to `}` represents a non empty selection with the head at `}` + // `{` to `]` represents a non empty selection with the head at `{` + pub fn set_state(&mut self, text: &str) { + self.editor.update(self.cx, |editor, cx| { + let (unmarked_text, mut selection_ranges) = marked_text_ranges_by( + &text, + vec!['|'.into(), ('[', '}').into(), ('{', ']').into()], + ); + editor.set_text(unmarked_text, cx); + + let mut selections: Vec> = + selection_ranges.remove(&'|'.into()).unwrap_or_default(); + selections.extend( + selection_ranges + .remove(&('{', ']').into()) + .unwrap_or_default() + .into_iter() + .map(|range| range.end..range.start), + ); + selections.extend( + selection_ranges + .remove(&('[', '}').into()) + .unwrap_or_default(), + ); + + editor.change_selections(Some(Autoscroll::Fit), cx, |s| s.select_ranges(selections)); + }) + } + + // Asserts the editor state via a marked string. + // `|` characters represent empty selections + // `[` to `}` represents a non empty selection with the head at `}` + // `{` to `]` represents a non empty selection with the head at `{` + pub fn assert_editor_state(&mut self, text: &str) { + let (unmarked_text, mut selection_ranges) = marked_text_ranges_by( + &text, + vec!['|'.into(), ('[', '}').into(), ('{', ']').into()], + ); + let editor_text = self.editor_text(); + assert_eq!( + editor_text, unmarked_text, + "Unmarked text doesn't match editor text" + ); + + let expected_empty_selections = selection_ranges.remove(&'|'.into()).unwrap_or_default(); + let expected_reverse_selections = selection_ranges + .remove(&('{', ']').into()) + .unwrap_or_default(); + let expected_forward_selections = selection_ranges + .remove(&('[', '}').into()) + .unwrap_or_default(); + + self.assert_selections( + expected_empty_selections, + expected_reverse_selections, + expected_forward_selections, + Some(text.to_string()), + ) + } + + pub fn assert_editor_selections(&mut self, expected_selections: Vec>) { + let mut empty_selections = Vec::new(); + let mut reverse_selections = Vec::new(); + let mut forward_selections = Vec::new(); + + for selection in expected_selections { + let range = selection.range(); + if selection.is_empty() { + empty_selections.push(range); + } else if selection.reversed { + reverse_selections.push(range); + } else { + forward_selections.push(range) + } + } + + self.assert_selections( + empty_selections, + reverse_selections, + forward_selections, + None, + ) + } + + fn assert_selections( + &mut self, + expected_empty_selections: Vec>, + expected_reverse_selections: Vec>, + expected_forward_selections: Vec>, + asserted_text: Option, + ) { + let (empty_selections, reverse_selections, forward_selections) = + self.editor.read_with(self.cx, |editor, cx| { + let mut empty_selections = Vec::new(); + let mut reverse_selections = Vec::new(); + let mut forward_selections = Vec::new(); + + for selection in editor.selections.all::(cx) { + let range = selection.range(); + if selection.is_empty() { + empty_selections.push(range); + } else if selection.reversed { + reverse_selections.push(range); + } else { + forward_selections.push(range) + } + } + + (empty_selections, reverse_selections, forward_selections) + }); + + let asserted_selections = asserted_text.unwrap_or_else(|| { + self.insert_markers( + &expected_empty_selections, + &expected_reverse_selections, + &expected_forward_selections, + ) + }); + let actual_selections = + self.insert_markers(&empty_selections, &reverse_selections, &forward_selections); + + let unmarked_text = self.editor_text(); + let all_eq: Result<(), SetEqError> = + set_eq!(expected_empty_selections, empty_selections) + .map_err(|err| { + err.map(|missing| { + let mut error_text = unmarked_text.clone(); + error_text.insert(missing.start, '|'); + error_text + }) + }) + .and_then(|_| { + set_eq!(expected_reverse_selections, reverse_selections).map_err(|err| { + err.map(|missing| { + let mut error_text = unmarked_text.clone(); + error_text.insert(missing.start, '{'); + error_text.insert(missing.end, ']'); + error_text + }) + }) + }) + .and_then(|_| { + set_eq!(expected_forward_selections, forward_selections).map_err(|err| { + err.map(|missing| { + let mut error_text = unmarked_text.clone(); + error_text.insert(missing.start, '['); + error_text.insert(missing.end, '}'); + error_text + }) + }) + }); + + match all_eq { + Err(SetEqError::LeftMissing(location_text)) => { + panic!( + indoc! {" + Editor has extra selection + Extra Selection Location: + {} + Asserted selections: + {} + Actual selections: + {}"}, + location_text, asserted_selections, actual_selections, + ); + } + Err(SetEqError::RightMissing(location_text)) => { + panic!( + indoc! {" + Editor is missing empty selection + Missing Selection Location: + {} + Asserted selections: + {} + Actual selections: + {}"}, + location_text, asserted_selections, actual_selections, + ); + } + _ => {} + } + } + + fn insert_markers( + &mut self, + empty_selections: &Vec>, + reverse_selections: &Vec>, + forward_selections: &Vec>, + ) -> String { + let mut editor_text_with_selections = self.editor_text(); + let mut selection_marks = BTreeMap::new(); + for range in empty_selections { + selection_marks.insert(&range.start, '|'); + } + for range in reverse_selections { + selection_marks.insert(&range.start, '{'); + selection_marks.insert(&range.end, ']'); + } + for range in forward_selections { + selection_marks.insert(&range.start, '['); + selection_marks.insert(&range.end, '}'); + } + for (offset, mark) in selection_marks.into_iter().rev() { + editor_text_with_selections.insert(*offset, mark); + } + + editor_text_with_selections + } + + pub fn assert_clipboard_content(&mut self, expected_content: Option<&str>) { + self.cx.update(|cx| { + let actual_content = cx.read_from_clipboard().map(|item| item.text().to_owned()); + let expected_content = expected_content.map(|content| content.to_owned()); + assert_eq!(actual_content, expected_content); + }) + } +} + +impl<'a> Deref for EditorTestContext<'a> { + type Target = gpui::TestAppContext; + + fn deref(&self) -> &Self::Target { + self.cx + } +} + +impl<'a> DerefMut for EditorTestContext<'a> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.cx + } +} diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 6b94ae40f6e325c98360fae00536724cde5d2e07..2e10606c09669518544ea1527601fe5d2fe78db0 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -544,12 +544,23 @@ impl TestAppContext { !prompts.is_empty() } - #[cfg(any(test, feature = "test-support"))] + pub fn current_window_title(&self, window_id: usize) -> Option { + let mut state = self.cx.borrow_mut(); + let (_, window) = state + .presenters_and_platform_windows + .get_mut(&window_id) + .unwrap(); + let test_window = window + .as_any_mut() + .downcast_mut::() + .unwrap(); + test_window.title.clone() + } + pub fn leak_detector(&self) -> Arc> { self.cx.borrow().leak_detector() } - #[cfg(any(test, feature = "test-support"))] pub fn assert_dropped(&self, handle: impl WeakHandle) { self.cx .borrow() @@ -757,7 +768,7 @@ type SubscriptionCallback = Box b type GlobalSubscriptionCallback = Box; type ObservationCallback = Box bool>; type FocusObservationCallback = Box bool>; -type GlobalObservationCallback = Box; +type GlobalObservationCallback = Box; type ReleaseObservationCallback = Box; type ActionObservationCallback = Box; type DeserializeActionCallback = fn(json: &str) -> anyhow::Result>; @@ -1272,7 +1283,7 @@ impl MutableAppContext { pub fn observe_global(&mut self, mut observe: F) -> Subscription where G: Any, - F: 'static + FnMut(&G, &mut MutableAppContext), + F: 'static + FnMut(&mut MutableAppContext), { let type_id = TypeId::of::(); let id = post_inc(&mut self.next_subscription_id); @@ -1283,11 +1294,8 @@ impl MutableAppContext { .or_default() .insert( id, - Some( - Box::new(move |global: &dyn Any, cx: &mut MutableAppContext| { - observe(global.downcast_ref().unwrap(), cx) - }) as GlobalObservationCallback, - ), + Some(Box::new(move |cx: &mut MutableAppContext| observe(cx)) + as GlobalObservationCallback), ); Subscription::GlobalObservation { @@ -2304,27 +2312,24 @@ impl MutableAppContext { fn handle_global_notification_effect(&mut self, observed_type_id: TypeId) { let callbacks = self.global_observations.lock().remove(&observed_type_id); if let Some(callbacks) = callbacks { - if let Some(global) = self.cx.globals.remove(&observed_type_id) { - for (id, callback) in callbacks { - if let Some(mut callback) = callback { - callback(global.as_ref(), self); - match self - .global_observations - .lock() - .entry(observed_type_id) - .or_default() - .entry(id) - { - collections::btree_map::Entry::Vacant(entry) => { - entry.insert(Some(callback)); - } - collections::btree_map::Entry::Occupied(entry) => { - entry.remove(); - } + for (id, callback) in callbacks { + if let Some(mut callback) = callback { + callback(self); + match self + .global_observations + .lock() + .entry(observed_type_id) + .or_default() + .entry(id) + { + collections::btree_map::Entry::Vacant(entry) => { + entry.insert(Some(callback)); + } + collections::btree_map::Entry::Occupied(entry) => { + entry.remove(); } } } - self.cx.globals.insert(observed_type_id, global); } } } @@ -3303,6 +3308,13 @@ impl<'a, T: View> ViewContext<'a, T> { self.app.focus(self.window_id, None); } + pub fn set_window_title(&mut self, title: &str) { + let window_id = self.window_id(); + if let Some((_, window)) = self.presenters_and_platform_windows.get_mut(&window_id) { + window.set_title(title); + } + } + pub fn add_model(&mut self, build_model: F) -> ModelHandle where S: Entity, @@ -5723,7 +5735,7 @@ mod tests { let observation_count = Rc::new(RefCell::new(0)); let subscription = cx.observe_global::({ let observation_count = observation_count.clone(); - move |_, _| { + move |_| { *observation_count.borrow_mut() += 1; } }); @@ -5753,7 +5765,7 @@ mod tests { let observation_count = Rc::new(RefCell::new(0)); cx.observe_global::({ let observation_count = observation_count.clone(); - move |_, _| { + move |_| { *observation_count.borrow_mut() += 1; } }) @@ -6127,7 +6139,7 @@ mod tests { *subscription.borrow_mut() = Some(cx.observe_global::<(), _>({ let observation_count = observation_count.clone(); let subscription = subscription.clone(); - move |_, _| { + move |_| { subscription.borrow_mut().take(); *observation_count.borrow_mut() += 1; } diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index c4b68c0741703530cba643c67515d6e90a06f452..16a6481a4346475b7dd700a2820dd890bcf92684 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -96,6 +96,7 @@ pub trait Window: WindowContext { fn on_close(&mut self, callback: Box); fn prompt(&self, level: PromptLevel, msg: &str, answers: &[&str]) -> oneshot::Receiver; fn activate(&self); + fn set_title(&mut self, title: &str); } pub trait WindowContext { diff --git a/crates/gpui/src/platform/mac/platform.rs b/crates/gpui/src/platform/mac/platform.rs index 26cde46c0492eb82138192c962e83f553baeb876..7ace58f4282365707bb84f840a24f9f2d912acd8 100644 --- a/crates/gpui/src/platform/mac/platform.rs +++ b/crates/gpui/src/platform/mac/platform.rs @@ -202,6 +202,11 @@ impl MacForegroundPlatform { menu_bar_item.setSubmenu_(menu); menu_bar.addItem_(menu_bar_item); + + if menu_name == "Window" { + let app: id = msg_send![APP_CLASS, sharedApplication]; + app.setWindowsMenu_(menu); + } } menu_bar diff --git a/crates/gpui/src/platform/mac/window.rs b/crates/gpui/src/platform/mac/window.rs index 518cefcd60aebde6329e5a3c8c184dc8ccd660ca..5d6848cd7bd1311af8f44d8ef804ae3162b736aa 100644 --- a/crates/gpui/src/platform/mac/window.rs +++ b/crates/gpui/src/platform/mac/window.rs @@ -386,8 +386,15 @@ impl platform::Window for Window { } fn activate(&self) { + unsafe { msg_send![self.0.borrow().native_window, makeKeyAndOrderFront: nil] } + } + + fn set_title(&mut self, title: &str) { unsafe { - let _: () = msg_send![self.0.borrow().native_window, makeKeyAndOrderFront: nil]; + let app = NSApplication::sharedApplication(nil); + let window = self.0.borrow().native_window; + let title = ns_string(title); + msg_send![app, changeWindowsItem:window title:title filename:false] } } } diff --git a/crates/gpui/src/platform/test.rs b/crates/gpui/src/platform/test.rs index a3d5cc540678ca744e1eac7d0f5ed77572b32929..e22db89e3b92ac0eabb1e3677846a4245ae53312 100644 --- a/crates/gpui/src/platform/test.rs +++ b/crates/gpui/src/platform/test.rs @@ -37,6 +37,7 @@ pub struct Window { event_handlers: Vec>, resize_handlers: Vec>, close_handlers: Vec>, + pub(crate) title: Option, pub(crate) pending_prompts: RefCell>>, } @@ -189,9 +190,14 @@ impl Window { close_handlers: Vec::new(), scale_factor: 1.0, current_scene: None, + title: None, pending_prompts: Default::default(), } } + + pub fn title(&self) -> Option { + self.title.clone() + } } impl super::Dispatcher for Dispatcher { @@ -248,6 +254,10 @@ impl super::Window for Window { } fn activate(&self) {} + + fn set_title(&mut self, title: &str) { + self.title = Some(title.to_string()) + } } pub fn platform() -> Platform { diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 647481367affe62e38e417accd868f8f59f1bd30..ccb3d382ea89c865cee61d942f475f496c68ed1a 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -83,6 +83,7 @@ pub struct BufferSnapshot { #[derive(Clone, Debug)] struct SelectionSet { + line_mode: bool, selections: Arc<[Selection]>, lamport_timestamp: clock::Lamport, } @@ -129,6 +130,7 @@ pub enum Operation { UpdateSelections { selections: Arc<[Selection]>, lamport_timestamp: clock::Lamport, + line_mode: bool, }, UpdateCompletionTriggers { triggers: Vec, @@ -343,6 +345,7 @@ impl Buffer { this.remote_selections.insert( selection_set.replica_id as ReplicaId, SelectionSet { + line_mode: selection_set.line_mode, selections: proto::deserialize_selections(selection_set.selections), lamport_timestamp, }, @@ -385,6 +388,7 @@ impl Buffer { replica_id: *replica_id as u32, selections: proto::serialize_selections(&set.selections), lamport_timestamp: set.lamport_timestamp.value, + line_mode: set.line_mode, }) .collect(), diagnostics: proto::serialize_diagnostics(self.diagnostics.iter()), @@ -1030,6 +1034,7 @@ impl Buffer { pub fn set_active_selections( &mut self, selections: Arc<[Selection]>, + line_mode: bool, cx: &mut ModelContext, ) { let lamport_timestamp = self.text.lamport_clock.tick(); @@ -1038,11 +1043,13 @@ impl Buffer { SelectionSet { selections: selections.clone(), lamport_timestamp, + line_mode, }, ); self.send_operation( Operation::UpdateSelections { selections, + line_mode, lamport_timestamp, }, cx, @@ -1050,7 +1057,7 @@ impl Buffer { } pub fn remove_active_selections(&mut self, cx: &mut ModelContext) { - self.set_active_selections(Arc::from([]), cx); + self.set_active_selections(Arc::from([]), false, cx); } pub fn set_text(&mut self, text: T, cx: &mut ModelContext) -> Option @@ -1287,6 +1294,7 @@ impl Buffer { Operation::UpdateSelections { selections, lamport_timestamp, + line_mode, } => { if let Some(set) = self.remote_selections.get(&lamport_timestamp.replica_id) { if set.lamport_timestamp > lamport_timestamp { @@ -1299,6 +1307,7 @@ impl Buffer { SelectionSet { selections, lamport_timestamp, + line_mode, }, ); self.text.lamport_clock.observe(lamport_timestamp); @@ -1890,8 +1899,14 @@ impl BufferSnapshot { pub fn remote_selections_in_range<'a>( &'a self, range: Range, - ) -> impl 'a + Iterator>)> - { + ) -> impl 'a + + Iterator< + Item = ( + ReplicaId, + bool, + impl 'a + Iterator>, + ), + > { self.remote_selections .iter() .filter(|(replica_id, set)| { @@ -1909,7 +1924,11 @@ impl BufferSnapshot { Ok(ix) | Err(ix) => ix, }; - (*replica_id, set.selections[start_ix..end_ix].iter()) + ( + *replica_id, + set.line_mode, + set.selections[start_ix..end_ix].iter(), + ) }) } diff --git a/crates/language/src/proto.rs b/crates/language/src/proto.rs index 312b192cb9f1b907bff763f3a2b31d7f82522d0c..d0a10df5a845149a6e69617511964abed857ecad 100644 --- a/crates/language/src/proto.rs +++ b/crates/language/src/proto.rs @@ -43,11 +43,13 @@ pub fn serialize_operation(operation: &Operation) -> proto::Operation { }), Operation::UpdateSelections { selections, + line_mode, lamport_timestamp, } => proto::operation::Variant::UpdateSelections(proto::operation::UpdateSelections { replica_id: lamport_timestamp.replica_id as u32, lamport_timestamp: lamport_timestamp.value, selections: serialize_selections(selections), + line_mode: *line_mode, }), Operation::UpdateDiagnostics { diagnostics, @@ -217,6 +219,7 @@ pub fn deserialize_operation(message: proto::Operation) -> Result { value: message.lamport_timestamp, }, selections: Arc::from(selections), + line_mode: message.line_mode, } } proto::operation::Variant::UpdateDiagnostics(message) => Operation::UpdateDiagnostics { diff --git a/crates/language/src/tests.rs b/crates/language/src/tests.rs index 527c13bfe866b35f20cbcdb295aa648eb8d8eaaa..3bc9f4b9dcfcaea660688f03f658f2579f3b342f 100644 --- a/crates/language/src/tests.rs +++ b/crates/language/src/tests.rs @@ -828,7 +828,7 @@ fn test_random_collaboration(cx: &mut MutableAppContext, mut rng: StdRng) { selections ); active_selections.insert(replica_id, selections.clone()); - buffer.set_active_selections(selections, cx); + buffer.set_active_selections(selections, false, cx); }); mutation_count -= 1; } @@ -984,7 +984,7 @@ fn test_random_collaboration(cx: &mut MutableAppContext, mut rng: StdRng) { let buffer = buffer.read(cx).snapshot(); let actual_remote_selections = buffer .remote_selections_in_range(Anchor::MIN..Anchor::MAX) - .map(|(replica_id, selections)| (replica_id, selections.collect::>())) + .map(|(replica_id, _, selections)| (replica_id, selections.collect::>())) .collect::>(); let expected_remote_selections = active_selections .iter() diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 5346a869653e4fe5f5f9b884c798394b9d6d3afc..0924c4b866302d44cd50e5d846fea123d467cddd 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -139,6 +139,7 @@ pub struct Collaborator { #[derive(Clone, Debug, PartialEq, Eq)] pub enum Event { ActiveEntryChanged(Option), + WorktreeAdded, WorktreeRemoved(WorktreeId), DiskBasedDiagnosticsStarted, DiskBasedDiagnosticsUpdated, @@ -3655,11 +3656,19 @@ impl Project { }) } - pub fn remove_worktree(&mut self, id: WorktreeId, cx: &mut ModelContext) { + pub fn remove_worktree(&mut self, id_to_remove: WorktreeId, cx: &mut ModelContext) { self.worktrees.retain(|worktree| { - worktree - .upgrade(cx) - .map_or(false, |w| w.read(cx).id() != id) + if let Some(worktree) = worktree.upgrade(cx) { + let id = worktree.read(cx).id(); + if id == id_to_remove { + cx.emit(Event::WorktreeRemoved(id)); + false + } else { + true + } + } else { + false + } }); cx.notify(); } @@ -3690,6 +3699,7 @@ impl Project { self.worktrees .push(WorktreeHandle::Weak(worktree.downgrade())); } + cx.emit(Event::WorktreeAdded); cx.notify(); } diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index e72cce6b6d2da086e97a19517346df1660fb414d..3a0243a08f918c6dd997e9551bb7412f1183d170 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -787,6 +787,7 @@ message SelectionSet { uint32 replica_id = 1; repeated Selection selections = 2; uint32 lamport_timestamp = 3; + bool line_mode = 4; } message Selection { @@ -862,6 +863,7 @@ message Operation { uint32 replica_id = 1; uint32 lamport_timestamp = 2; repeated Selection selections = 3; + bool line_mode = 4; } message UpdateCompletionTriggers { diff --git a/crates/rpc/src/rpc.rs b/crates/rpc/src/rpc.rs index 175661aacfd6d28802e67b92fe97c5d7a4c0be04..27b666d6d08896fbd345a9b4b4cc26ad43ac222c 100644 --- a/crates/rpc/src/rpc.rs +++ b/crates/rpc/src/rpc.rs @@ -6,4 +6,4 @@ pub use conn::Connection; pub use peer::*; mod macros; -pub const PROTOCOL_VERSION: u32 = 19; +pub const PROTOCOL_VERSION: u32 = 20; diff --git a/crates/settings/src/settings.rs b/crates/settings/src/settings.rs index 9b5ed124412d697166b52217488b0f67e6263bf4..58c70d32c1dcebc37cee699f31f1b45f5fd6351a 100644 --- a/crates/settings/src/settings.rs +++ b/crates/settings/src/settings.rs @@ -21,6 +21,7 @@ pub use keymap_file::{keymap_file_json_schema, KeymapFileContent}; pub struct Settings { pub buffer_font_family: FamilyId, pub buffer_font_size: f32, + pub default_buffer_font_size: f32, pub vim_mode: bool, pub tab_size: u32, pub soft_wrap: SoftWrap, @@ -73,6 +74,7 @@ impl Settings { Ok(Self { buffer_font_family: font_cache.load_family(&[buffer_font_family])?, buffer_font_size: 15., + default_buffer_font_size: 15., vim_mode: false, tab_size: 4, soft_wrap: SoftWrap::None, @@ -126,6 +128,7 @@ impl Settings { Settings { buffer_font_family: cx.font_cache().load_family(&["Monaco"]).unwrap(), buffer_font_size: 14., + default_buffer_font_size: 14., vim_mode: false, tab_size: 4, soft_wrap: SoftWrap::None, @@ -162,6 +165,7 @@ impl Settings { } merge(&mut self.buffer_font_size, data.buffer_font_size); + merge(&mut self.default_buffer_font_size, data.buffer_font_size); merge(&mut self.vim_mode, data.vim_mode); merge(&mut self.format_on_save, data.format_on_save); merge(&mut self.soft_wrap, data.editor.soft_wrap); diff --git a/crates/text/src/selection.rs b/crates/text/src/selection.rs index 8dcc3fc7f18974eef49fa2b96297e57977b82ab1..fd8d57ca9f81a597a309483d3355cdcb9aa79afc 100644 --- a/crates/text/src/selection.rs +++ b/crates/text/src/selection.rs @@ -1,6 +1,7 @@ use crate::Anchor; use crate::{rope::TextDimension, BufferSnapshot}; use std::cmp::Ordering; +use std::ops::Range; #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum SelectionGoal { @@ -83,6 +84,10 @@ impl Selection { self.goal = new_goal; self.reversed = false; } + + pub fn range(&self) -> Range { + self.start..self.end + } } impl Selection { diff --git a/crates/util/src/test/marked_text.rs b/crates/util/src/test/marked_text.rs index 23ac35ce86c7f095679a53b817b5b44cc4e7f340..733feeb3f8e6739607b304d3c3b2d82c963cfe97 100644 --- a/crates/util/src/test/marked_text.rs +++ b/crates/util/src/test/marked_text.rs @@ -24,31 +24,67 @@ pub fn marked_text(marked_text: &str) -> (String, Vec) { (unmarked_text, markers.remove(&'|').unwrap_or_default()) } +#[derive(Eq, PartialEq, Hash)] +pub enum TextRangeMarker { + Empty(char), + Range(char, char), +} + +impl TextRangeMarker { + fn markers(&self) -> Vec { + match self { + Self::Empty(m) => vec![*m], + Self::Range(l, r) => vec![*l, *r], + } + } +} + +impl From for TextRangeMarker { + fn from(marker: char) -> Self { + Self::Empty(marker) + } +} + +impl From<(char, char)> for TextRangeMarker { + fn from((left_marker, right_marker): (char, char)) -> Self { + Self::Range(left_marker, right_marker) + } +} + pub fn marked_text_ranges_by( marked_text: &str, - delimiters: Vec<(char, char)>, -) -> (String, HashMap<(char, char), Vec>>) { - let all_markers = delimiters - .iter() - .flat_map(|(start, end)| [*start, *end]) - .collect(); - let (unmarked_text, mut markers) = marked_text_by(marked_text, all_markers); - let range_lookup = delimiters + markers: Vec, +) -> (String, HashMap>>) { + let all_markers = markers.iter().flat_map(|m| m.markers()).collect(); + + let (unmarked_text, mut marker_offsets) = marked_text_by(marked_text, all_markers); + let range_lookup = markers .into_iter() - .map(|(start_marker, end_marker)| { - let starts = markers.remove(&start_marker).unwrap_or_default(); - let ends = markers.remove(&end_marker).unwrap_or_default(); - assert_eq!(starts.len(), ends.len(), "marked ranges are unbalanced"); + .map(|marker| match marker { + TextRangeMarker::Empty(empty_marker_char) => { + let ranges = marker_offsets + .remove(&empty_marker_char) + .unwrap_or_default() + .into_iter() + .map(|empty_index| empty_index..empty_index) + .collect::>>(); + (marker, ranges) + } + TextRangeMarker::Range(start_marker, end_marker) => { + let starts = marker_offsets.remove(&start_marker).unwrap_or_default(); + let ends = marker_offsets.remove(&end_marker).unwrap_or_default(); + assert_eq!(starts.len(), ends.len(), "marked ranges are unbalanced"); - let ranges = starts - .into_iter() - .zip(ends) - .map(|(start, end)| { - assert!(end >= start, "marked ranges must be disjoint"); - start..end - }) - .collect::>>(); - ((start_marker, end_marker), ranges) + let ranges = starts + .into_iter() + .zip(ends) + .map(|(start, end)| { + assert!(end >= start, "marked ranges must be disjoint"); + start..end + }) + .collect::>>(); + (marker, ranges) + } }) .collect(); @@ -58,14 +94,16 @@ pub fn marked_text_ranges_by( // Returns ranges delimited by (), [], and <> ranges. Ranges using the same markers // must not be overlapping. May also include | for empty ranges pub fn marked_text_ranges(full_marked_text: &str) -> (String, Vec>) { - let (range_marked_text, empty_offsets) = marked_text(full_marked_text); - let (unmarked, range_lookup) = - marked_text_ranges_by(&range_marked_text, vec![('[', ']'), ('(', ')'), ('<', '>')]); - let mut combined_ranges: Vec<_> = range_lookup - .into_values() - .flatten() - .chain(empty_offsets.into_iter().map(|offset| offset..offset)) - .collect(); + let (unmarked, range_lookup) = marked_text_ranges_by( + &full_marked_text, + vec![ + '|'.into(), + ('[', ']').into(), + ('(', ')').into(), + ('<', '>').into(), + ], + ); + let mut combined_ranges: Vec<_> = range_lookup.into_values().flatten().collect(); combined_ranges.sort_by_key(|range| range.start); (unmarked, combined_ranges) diff --git a/crates/vim/src/editor_events.rs b/crates/vim/src/editor_events.rs index f9dfc588e12a0c9536c174ec3096d879f1ebf4b4..8837f264d35eb40233685dfbf30ac5189359b618 100644 --- a/crates/vim/src/editor_events.rs +++ b/crates/vim/src/editor_events.rs @@ -18,22 +18,31 @@ fn editor_created(EditorCreated(editor): &EditorCreated, cx: &mut MutableAppCont } fn editor_focused(EditorFocused(editor): &EditorFocused, cx: &mut MutableAppContext) { - Vim::update(cx, |state, cx| { - state.active_editor = Some(editor.downgrade()); + Vim::update(cx, |vim, cx| { + vim.active_editor = Some(editor.downgrade()); + vim.selection_subscription = Some(cx.subscribe(editor, |editor, event, cx| { + if editor.read(cx).leader_replica_id().is_none() { + if let editor::Event::SelectionsChanged { local: true } = event { + let newest_empty = editor.read(cx).selections.newest::(cx).is_empty(); + editor_local_selections_changed(newest_empty, cx); + } + } + })); + if editor.read(cx).mode() != EditorMode::Full { - state.switch_mode(Mode::Insert, cx); + vim.switch_mode(Mode::Insert, cx); } }); } fn editor_blurred(EditorBlurred(editor): &EditorBlurred, cx: &mut MutableAppContext) { - Vim::update(cx, |state, cx| { - if let Some(previous_editor) = state.active_editor.clone() { + Vim::update(cx, |vim, cx| { + if let Some(previous_editor) = vim.active_editor.clone() { if previous_editor == editor.clone() { - state.active_editor = None; + vim.active_editor = None; } } - state.sync_editor_options(cx); + vim.sync_editor_options(cx); }) } @@ -47,3 +56,11 @@ fn editor_released(EditorReleased(editor): &EditorReleased, cx: &mut MutableAppC } }); } + +fn editor_local_selections_changed(newest_empty: bool, cx: &mut MutableAppContext) { + Vim::update(cx, |vim, cx| { + if vim.enabled && vim.state.mode == Mode::Normal && !newest_empty { + vim.switch_mode(Mode::Visual { line: false }, cx) + } + }) +} diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index a38d10c8f8be4fc1d701ef12480afd4db0ab54e8..221898c056220246cfd59d56b50f23639ce5953e 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -111,7 +111,7 @@ fn motion(motion: Motion, cx: &mut MutableAppContext) { }); match Vim::read(cx).state.mode { Mode::Normal => normal_motion(motion, cx), - Mode::Visual => visual_motion(motion, cx), + Mode::Visual { .. } => visual_motion(motion, cx), Mode::Insert => { // Shouldn't execute a motion in insert mode. Ignoring } @@ -192,11 +192,13 @@ impl Motion { if selection.end.row() < map.max_point().row() { *selection.end.row_mut() += 1; *selection.end.column_mut() = 0; + selection.end = map.clip_point(selection.end, Bias::Right); // Don't reset the end here return; } else if selection.start.row() > 0 { *selection.start.row_mut() -= 1; *selection.start.column_mut() = map.line_len(selection.start.row()); + selection.start = map.clip_point(selection.start, Bias::Left); } } diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 2a391676fa8e0b8a5796be153386f96a054b66bd..55c9779581d19cd2fb4fd38dd4097eeb54aa97ed 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -1,5 +1,8 @@ mod change; mod delete; +mod yank; + +use std::borrow::Cow; use crate::{ motion::Motion, @@ -8,12 +11,12 @@ use crate::{ }; use change::init as change_init; use collections::HashSet; -use editor::{Autoscroll, Bias, DisplayPoint}; +use editor::{Autoscroll, Bias, ClipboardSelection, DisplayPoint}; use gpui::{actions, MutableAppContext, ViewContext}; -use language::SelectionGoal; +use language::{Point, SelectionGoal}; use workspace::Workspace; -use self::{change::change_over, delete::delete_over}; +use self::{change::change_over, delete::delete_over, yank::yank_over}; actions!( vim, @@ -27,6 +30,8 @@ actions!( DeleteRight, ChangeToEndOfLine, DeleteToEndOfLine, + Paste, + Yank, ] ); @@ -56,6 +61,7 @@ pub fn init(cx: &mut MutableAppContext) { delete_over(vim, Motion::EndOfLine, cx); }) }); + cx.add_action(paste); change_init(cx); } @@ -64,11 +70,12 @@ pub fn normal_motion(motion: Motion, cx: &mut MutableAppContext) { Vim::update(cx, |vim, cx| { match vim.state.operator_stack.pop() { None => move_cursor(vim, motion, cx), - Some(Operator::Change) => change_over(vim, motion, cx), - Some(Operator::Delete) => delete_over(vim, motion, cx), Some(Operator::Namespace(_)) => { // Can't do anything for a namespace operator. Ignoring } + Some(Operator::Change) => change_over(vim, motion, cx), + Some(Operator::Delete) => delete_over(vim, motion, cx), + Some(Operator::Yank) => yank_over(vim, motion, cx), } vim.clear_operator(cx); }); @@ -187,6 +194,116 @@ fn insert_line_below(_: &mut Workspace, _: &InsertLineBelow, cx: &mut ViewContex }); } +// Supports non empty selections so it can be bound and called from visual mode +fn paste(_: &mut Workspace, _: &Paste, cx: &mut ViewContext) { + Vim::update(cx, |vim, cx| { + vim.update_active_editor(cx, |editor, cx| { + editor.transact(cx, |editor, cx| { + if let Some(item) = cx.as_mut().read_from_clipboard() { + let mut clipboard_text = Cow::Borrowed(item.text()); + if let Some(mut clipboard_selections) = + item.metadata::>() + { + let (display_map, selections) = editor.selections.all_display(cx); + let all_selections_were_entire_line = + clipboard_selections.iter().all(|s| s.is_entire_line); + if clipboard_selections.len() != selections.len() { + let mut newline_separated_text = String::new(); + let mut clipboard_selections = + clipboard_selections.drain(..).peekable(); + let mut ix = 0; + while let Some(clipboard_selection) = clipboard_selections.next() { + newline_separated_text + .push_str(&clipboard_text[ix..ix + clipboard_selection.len]); + ix += clipboard_selection.len; + if clipboard_selections.peek().is_some() { + newline_separated_text.push('\n'); + } + } + clipboard_text = Cow::Owned(newline_separated_text); + } + + let mut new_selections = Vec::new(); + editor.buffer().update(cx, |buffer, cx| { + let snapshot = buffer.snapshot(cx); + let mut start_offset = 0; + let mut edits = Vec::new(); + for (ix, selection) in selections.iter().enumerate() { + let to_insert; + let linewise; + if let Some(clipboard_selection) = clipboard_selections.get(ix) { + let end_offset = start_offset + clipboard_selection.len; + to_insert = &clipboard_text[start_offset..end_offset]; + linewise = clipboard_selection.is_entire_line; + start_offset = end_offset; + } else { + to_insert = clipboard_text.as_str(); + linewise = all_selections_were_entire_line; + } + + // If the clipboard text was copied linewise, and the current selection + // is empty, then paste the text after this line and move the selection + // to the start of the pasted text + let range = if selection.is_empty() && linewise { + let (point, _) = display_map + .next_line_boundary(selection.start.to_point(&display_map)); + + if !to_insert.starts_with('\n') { + // Add newline before pasted text so that it shows up + edits.push((point..point, "\n")); + } + // Drop selection at the start of the next line + let selection_point = Point::new(point.row + 1, 0); + new_selections.push(selection.map(|_| selection_point.clone())); + point..point + } else { + let mut selection = selection.clone(); + if !selection.reversed { + let mut adjusted = selection.end; + // Head is at the end of the selection. Adjust the end position to + // to include the character under the cursor. + *adjusted.column_mut() = adjusted.column() + 1; + adjusted = display_map.clip_point(adjusted, Bias::Right); + // If the selection is empty, move both the start and end forward one + // character + if selection.is_empty() { + selection.start = adjusted; + selection.end = adjusted; + } else { + selection.end = adjusted; + } + } + + let range = selection.map(|p| p.to_point(&display_map)).range(); + new_selections.push(selection.map(|_| range.start.clone())); + range + }; + + if linewise && to_insert.ends_with('\n') { + edits.push(( + range, + &to_insert[0..to_insert.len().saturating_sub(1)], + )) + } else { + edits.push((range, to_insert)); + } + } + drop(snapshot); + buffer.edit_with_autoindent(edits, cx); + }); + + editor.change_selections(Some(Autoscroll::Fit), cx, |s| { + s.select(new_selections) + }); + } else { + editor.insert(&clipboard_text, cx); + } + } + }); + }); + }); +} + #[cfg(test)] mod test { use indoc::indoc; @@ -678,14 +795,8 @@ mod test { | The quick"}, ); - cx.assert( - indoc! {" - | - The quick"}, - indoc! {" - | - The quick"}, - ); + // Indoc disallows trailing whitspace. + cx.assert(" | \nThe quick", " | \nThe quick"); } #[gpui::test] @@ -1026,4 +1137,48 @@ mod test { brown fox"}, ); } + + #[gpui::test] + async fn test_p(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.set_state( + indoc! {" + The quick brown + fox ju|mps over + the lazy dog"}, + Mode::Normal, + ); + + cx.simulate_keystrokes(["d", "d"]); + cx.assert_editor_state(indoc! {" + The quick brown + the la|zy dog"}); + + cx.simulate_keystroke("p"); + cx.assert_editor_state(indoc! {" + The quick brown + the lazy dog + |fox jumps over"}); + + cx.set_state( + indoc! {" + The quick brown + fox [jump}s over + the lazy dog"}, + Mode::Normal, + ); + cx.simulate_keystroke("y"); + cx.set_state( + indoc! {" + The quick brown + fox jump|s over + the lazy dog"}, + Mode::Normal, + ); + cx.simulate_keystroke("p"); + cx.assert_editor_state(indoc! {" + The quick brown + fox jumps|jumps over + the lazy dog"}); + } } diff --git a/crates/vim/src/normal/change.rs b/crates/vim/src/normal/change.rs index 8124f8a2006c974d8dace98648d7128c8d881aa2..7f417fd31ed3167097e6eeeff52f14fc9024b690 100644 --- a/crates/vim/src/normal/change.rs +++ b/crates/vim/src/normal/change.rs @@ -1,4 +1,4 @@ -use crate::{motion::Motion, state::Mode, Vim}; +use crate::{motion::Motion, state::Mode, utils::copy_selections_content, Vim}; use editor::{char_kind, movement, Autoscroll}; use gpui::{impl_actions, MutableAppContext, ViewContext}; use serde::Deserialize; @@ -27,6 +27,7 @@ pub fn change_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) { motion.expand_selection(map, selection, false); }); }); + copy_selections_content(editor, motion.linewise(), cx); editor.insert(&"", cx); }); }); @@ -65,6 +66,7 @@ fn change_word( }); }); }); + copy_selections_content(editor, false, cx); editor.insert(&"", cx); }); }); diff --git a/crates/vim/src/normal/delete.rs b/crates/vim/src/normal/delete.rs index b44f0a1f34890142904463e530d7a4e76cd81d5f..cea607e9f3dbba956dc6436d0965ccc87bdd1021 100644 --- a/crates/vim/src/normal/delete.rs +++ b/crates/vim/src/normal/delete.rs @@ -1,4 +1,4 @@ -use crate::{motion::Motion, Vim}; +use crate::{motion::Motion, utils::copy_selections_content, Vim}; use collections::HashMap; use editor::{Autoscroll, Bias}; use gpui::MutableAppContext; @@ -15,6 +15,7 @@ pub fn delete_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) { original_columns.insert(selection.id, original_head.column()); }); }); + copy_selections_content(editor, motion.linewise(), cx); editor.insert(&"", cx); // Fixup cursor position after the deletion diff --git a/crates/vim/src/normal/yank.rs b/crates/vim/src/normal/yank.rs new file mode 100644 index 0000000000000000000000000000000000000000..17a9e47d3d84b8a491c8dd837c7e8f975a422c74 --- /dev/null +++ b/crates/vim/src/normal/yank.rs @@ -0,0 +1,26 @@ +use crate::{motion::Motion, utils::copy_selections_content, Vim}; +use collections::HashMap; +use gpui::MutableAppContext; + +pub fn yank_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) { + vim.update_active_editor(cx, |editor, cx| { + editor.transact(cx, |editor, cx| { + editor.set_clip_at_line_ends(false, cx); + let mut original_positions: HashMap<_, _> = Default::default(); + editor.change_selections(None, cx, |s| { + s.move_with(|map, selection| { + let original_position = (selection.head(), selection.goal); + motion.expand_selection(map, selection, true); + original_positions.insert(selection.id, original_position); + }); + }); + copy_selections_content(editor, motion.linewise(), cx); + editor.change_selections(None, cx, |s| { + s.move_with(|_, selection| { + let (head, goal) = original_positions.remove(&selection.id).unwrap(); + selection.collapse_to(head, goal); + }); + }); + }); + }); +} diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index b4d5cbe9c7e2f19b7e611ed6849d6bbae54cddbe..a08b8bd2d2103126a8d8c521c9737f9d2c1fe316 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -6,7 +6,7 @@ use serde::Deserialize; pub enum Mode { Normal, Insert, - Visual, + Visual { line: bool }, } impl Default for Mode { @@ -25,6 +25,7 @@ pub enum Operator { Namespace(Namespace), Change, Delete, + Yank, } #[derive(Default)] @@ -36,8 +37,7 @@ pub struct VimState { impl VimState { pub fn cursor_shape(&self) -> CursorShape { match self.mode { - Mode::Normal => CursorShape::Block, - Mode::Visual => CursorShape::Block, + Mode::Normal | Mode::Visual { .. } => CursorShape::Block, Mode::Insert => CursorShape::Bar, } } @@ -46,13 +46,24 @@ impl VimState { !matches!(self.mode, Mode::Insert) } + pub fn clip_at_line_end(&self) -> bool { + match self.mode { + Mode::Insert | Mode::Visual { .. } => false, + _ => true, + } + } + + pub fn empty_selections_only(&self) -> bool { + !matches!(self.mode, Mode::Visual { .. }) + } + pub fn keymap_context_layer(&self) -> Context { let mut context = Context::default(); context.map.insert( "vim_mode".to_string(), match self.mode { Mode::Normal => "normal", - Mode::Visual => "visual", + Mode::Visual { .. } => "visual", Mode::Insert => "insert", } .to_string(), @@ -75,6 +86,7 @@ impl Operator { Operator::Namespace(Namespace::G) => "g", Operator::Change => "c", Operator::Delete => "d", + Operator::Yank => "y", } .to_owned(); diff --git a/crates/vim/src/utils.rs b/crates/vim/src/utils.rs new file mode 100644 index 0000000000000000000000000000000000000000..cb6a736c6344d0c91cfdb7b5b22458ac0e9fed2e --- /dev/null +++ b/crates/vim/src/utils.rs @@ -0,0 +1,25 @@ +use editor::{ClipboardSelection, Editor}; +use gpui::{ClipboardItem, MutableAppContext}; + +pub fn copy_selections_content(editor: &mut Editor, linewise: bool, cx: &mut MutableAppContext) { + let selections = editor.selections.all_adjusted(cx); + let buffer = editor.buffer().read(cx).snapshot(cx); + let mut text = String::new(); + let mut clipboard_selections = Vec::with_capacity(selections.len()); + { + for selection in selections.iter() { + let initial_len = text.len(); + let start = selection.start; + let end = selection.end; + for chunk in buffer.text_for_range(start..end) { + text.push_str(chunk); + } + clipboard_selections.push(ClipboardSelection { + len: text.len() - initial_len, + is_entire_line: linewise, + }); + } + } + + cx.write_to_clipboard(ClipboardItem::new(text).with_metadata(clipboard_selections)); +} diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index f0731edd49b5cd3bfa14fa34f12899ae64920a99..89647b56e29f3e83c8174309e731850f9656f961 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -6,11 +6,12 @@ mod insert; mod motion; mod normal; mod state; +mod utils; mod visual; use collections::HashMap; -use editor::{CursorShape, Editor}; -use gpui::{impl_actions, MutableAppContext, ViewContext, WeakViewHandle}; +use editor::{Bias, CursorShape, Editor, Input}; +use gpui::{impl_actions, MutableAppContext, Subscription, ViewContext, WeakViewHandle}; use serde::Deserialize; use settings::Settings; @@ -40,9 +41,19 @@ pub fn init(cx: &mut MutableAppContext) { Vim::update(cx, |vim, cx| vim.push_operator(operator, cx)) }, ); + cx.add_action(|_: &mut Editor, _: &Input, cx| { + if Vim::read(cx).active_operator().is_some() { + // Defer without updating editor + MutableAppContext::defer(cx, |cx| Vim::update(cx, |vim, cx| vim.clear_operator(cx))) + } else { + cx.propagate_action() + } + }); - cx.observe_global::(|settings, cx| { - Vim::update(cx, |state, cx| state.set_enabled(settings.vim_mode, cx)) + cx.observe_global::(|cx| { + Vim::update(cx, |state, cx| { + state.set_enabled(cx.global::().vim_mode, cx) + }) }) .detach(); } @@ -51,6 +62,7 @@ pub fn init(cx: &mut MutableAppContext) { pub struct Vim { editors: HashMap>, active_editor: Option>, + selection_subscription: Option, enabled: bool, state: VimState, @@ -101,7 +113,7 @@ impl Vim { self.sync_editor_options(cx); } - fn active_operator(&mut self) -> Option { + fn active_operator(&self) -> Option { self.state.operator_stack.last().copied() } @@ -118,23 +130,38 @@ impl Vim { fn sync_editor_options(&self, cx: &mut MutableAppContext) { let state = &self.state; - let cursor_shape = state.cursor_shape(); + for editor in self.editors.values() { if let Some(editor) = editor.upgrade(cx) { editor.update(cx, |editor, cx| { if self.enabled { editor.set_cursor_shape(cursor_shape, cx); - editor.set_clip_at_line_ends(cursor_shape == CursorShape::Block, cx); + editor.set_clip_at_line_ends(state.clip_at_line_end(), cx); editor.set_input_enabled(!state.vim_controlled()); + editor.selections.line_mode = + matches!(state.mode, Mode::Visual { line: true }); let context_layer = state.keymap_context_layer(); editor.set_keymap_context_layer::(context_layer); } else { editor.set_cursor_shape(CursorShape::Bar, cx); editor.set_clip_at_line_ends(false, cx); editor.set_input_enabled(true); + editor.selections.line_mode = false; editor.remove_keymap_context_layer::(); } + + editor.change_selections(None, cx, |s| { + s.move_with(|map, selection| { + selection.set_head( + map.clip_point(selection.head(), Bias::Left), + selection.goal, + ); + if state.empty_selections_only() { + selection.collapse_to(selection.head(), selection.goal) + } + }); + }) }); } } @@ -169,9 +196,9 @@ mod test { assert_eq!(cx.mode(), Mode::Normal); cx.simulate_keystrokes(["h", "h", "h", "l"]); assert_eq!(cx.editor_text(), "hjkl".to_owned()); - cx.assert_editor_state("hj|kl"); + cx.assert_editor_state("h|jkl"); cx.simulate_keystrokes(["i", "T", "e", "s", "t"]); - cx.assert_editor_state("hjTest|kl"); + cx.assert_editor_state("hTest|jkl"); // Disabling and enabling resets to normal mode assert_eq!(cx.mode(), Mode::Insert); diff --git a/crates/vim/src/vim_test_context.rs b/crates/vim/src/vim_test_context.rs index f9080e554cb23638b44b5401d0c1ca762b078900..52d4778b383c1bc97c37df2f7b9162f7d3d9fdb8 100644 --- a/crates/vim/src/vim_test_context.rs +++ b/crates/vim/src/vim_test_context.rs @@ -1,31 +1,21 @@ -use std::ops::{Deref, Range}; +use std::ops::{Deref, DerefMut}; -use collections::BTreeMap; -use itertools::{Either, Itertools}; - -use editor::{display_map::ToDisplayPoint, Autoscroll}; -use gpui::{json::json, keymap::Keystroke, ViewHandle}; -use indoc::indoc; -use language::Selection; +use editor::test::EditorTestContext; +use gpui::json::json; use project::Project; -use util::{ - set_eq, - test::{marked_text, marked_text_ranges_by, SetEqError}, -}; -use workspace::{AppState, WorkspaceHandle}; +use workspace::{pane, AppState, WorkspaceHandle}; use crate::{state::Operator, *}; pub struct VimTestContext<'a> { - cx: &'a mut gpui::TestAppContext, - window_id: usize, - editor: ViewHandle, + cx: EditorTestContext<'a>, } impl<'a> VimTestContext<'a> { pub async fn new(cx: &'a mut gpui::TestAppContext, enabled: bool) -> VimTestContext<'a> { cx.update(|cx| { editor::init(cx); + pane::init(cx); crate::init(cx); settings::KeymapFileContent::load("keymaps/vim.json", cx).unwrap(); @@ -69,9 +59,11 @@ impl<'a> VimTestContext<'a> { editor.update(cx, |_, cx| cx.focus_self()); Self { - cx, - window_id, - editor, + cx: EditorTestContext { + cx, + window_id, + editor, + }, } } @@ -100,219 +92,13 @@ impl<'a> VimTestContext<'a> { .read(|cx| cx.global::().state.operator_stack.last().copied()) } - pub fn editor_text(&mut self) -> String { - self.editor - .update(self.cx, |editor, cx| editor.snapshot(cx).text()) - } - - pub fn simulate_keystroke(&mut self, keystroke_text: &str) { - let keystroke = Keystroke::parse(keystroke_text).unwrap(); - let input = if keystroke.modified() { - None - } else { - Some(keystroke.key.clone()) - }; - self.cx - .dispatch_keystroke(self.window_id, keystroke, input, false); - } - - pub fn simulate_keystrokes(&mut self, keystroke_texts: [&str; COUNT]) { - for keystroke_text in keystroke_texts.into_iter() { - self.simulate_keystroke(keystroke_text); - } - } - pub fn set_state(&mut self, text: &str, mode: Mode) { - self.cx - .update(|cx| Vim::update(cx, |vim, cx| vim.switch_mode(mode, cx))); - self.editor.update(self.cx, |editor, cx| { - let (unmarked_text, markers) = marked_text(&text); - editor.set_text(unmarked_text, cx); - let cursor_offset = markers[0]; - editor.change_selections(Some(Autoscroll::Fit), cx, |s| { - s.replace_cursors_with(|map| vec![cursor_offset.to_display_point(map)]) - }); - }) - } - - // Asserts the editor state via a marked string. - // `|` characters represent empty selections - // `[` to `}` represents a non empty selection with the head at `}` - // `{` to `]` represents a non empty selection with the head at `{` - pub fn assert_editor_state(&mut self, text: &str) { - let (text_with_ranges, expected_empty_selections) = marked_text(&text); - let (unmarked_text, mut selection_ranges) = - marked_text_ranges_by(&text_with_ranges, vec![('[', '}'), ('{', ']')]); - let editor_text = self.editor_text(); - assert_eq!( - editor_text, unmarked_text, - "Unmarked text doesn't match editor text" - ); - - let expected_reverse_selections = selection_ranges.remove(&('{', ']')).unwrap_or_default(); - let expected_forward_selections = selection_ranges.remove(&('[', '}')).unwrap_or_default(); - - self.assert_selections( - expected_empty_selections, - expected_reverse_selections, - expected_forward_selections, - Some(text.to_string()), - ) - } - - pub fn assert_editor_selections(&mut self, expected_selections: Vec>) { - let (expected_empty_selections, expected_non_empty_selections): (Vec<_>, Vec<_>) = - expected_selections.into_iter().partition_map(|selection| { - if selection.is_empty() { - Either::Left(selection.head()) - } else { - Either::Right(selection) - } - }); - - let (expected_reverse_selections, expected_forward_selections): (Vec<_>, Vec<_>) = - expected_non_empty_selections - .into_iter() - .partition_map(|selection| { - let range = selection.start..selection.end; - if selection.reversed { - Either::Left(range) - } else { - Either::Right(range) - } - }); - - self.assert_selections( - expected_empty_selections, - expected_reverse_selections, - expected_forward_selections, - None, - ) - } - - fn assert_selections( - &mut self, - expected_empty_selections: Vec, - expected_reverse_selections: Vec>, - expected_forward_selections: Vec>, - asserted_text: Option, - ) { - let (empty_selections, reverse_selections, forward_selections) = - self.editor.read_with(self.cx, |editor, cx| { - let (empty_selections, non_empty_selections): (Vec<_>, Vec<_>) = editor - .selections - .all::(cx) - .into_iter() - .partition_map(|selection| { - if selection.is_empty() { - Either::Left(selection.head()) - } else { - Either::Right(selection) - } - }); - - let (reverse_selections, forward_selections): (Vec<_>, Vec<_>) = - non_empty_selections.into_iter().partition_map(|selection| { - let range = selection.start..selection.end; - if selection.reversed { - Either::Left(range) - } else { - Either::Right(range) - } - }); - (empty_selections, reverse_selections, forward_selections) - }); - - let asserted_selections = asserted_text.unwrap_or_else(|| { - self.insert_markers( - &expected_empty_selections, - &expected_reverse_selections, - &expected_forward_selections, - ) + self.cx.update(|cx| { + Vim::update(cx, |vim, cx| { + vim.switch_mode(mode, cx); + }) }); - let actual_selections = - self.insert_markers(&empty_selections, &reverse_selections, &forward_selections); - - let unmarked_text = self.editor_text(); - let all_eq: Result<(), SetEqError> = - set_eq!(expected_empty_selections, empty_selections) - .map_err(|err| { - err.map(|missing| { - let mut error_text = unmarked_text.clone(); - error_text.insert(missing, '|'); - error_text - }) - }) - .and_then(|_| { - set_eq!(expected_reverse_selections, reverse_selections).map_err(|err| { - err.map(|missing| { - let mut error_text = unmarked_text.clone(); - error_text.insert(missing.start, '{'); - error_text.insert(missing.end, ']'); - error_text - }) - }) - }) - .and_then(|_| { - set_eq!(expected_forward_selections, forward_selections).map_err(|err| { - err.map(|missing| { - let mut error_text = unmarked_text.clone(); - error_text.insert(missing.start, '['); - error_text.insert(missing.end, '}'); - error_text - }) - }) - }); - - match all_eq { - Err(SetEqError::LeftMissing(location_text)) => { - panic!( - indoc! {" - Editor has extra selection - Extra Selection Location: {} - Asserted selections: {} - Actual selections: {}"}, - location_text, asserted_selections, actual_selections, - ); - } - Err(SetEqError::RightMissing(location_text)) => { - panic!( - indoc! {" - Editor is missing empty selection - Missing Selection Location: {} - Asserted selections: {} - Actual selections: {}"}, - location_text, asserted_selections, actual_selections, - ); - } - _ => {} - } - } - - fn insert_markers( - &mut self, - empty_selections: &Vec, - reverse_selections: &Vec>, - forward_selections: &Vec>, - ) -> String { - let mut editor_text_with_selections = self.editor_text(); - let mut selection_marks = BTreeMap::new(); - for offset in empty_selections { - selection_marks.insert(offset, '|'); - } - for range in reverse_selections { - selection_marks.insert(&range.start, '{'); - selection_marks.insert(&range.end, ']'); - } - for range in forward_selections { - selection_marks.insert(&range.start, '['); - selection_marks.insert(&range.end, '}'); - } - for (offset, mark) in selection_marks.into_iter().rev() { - editor_text_with_selections.insert(*offset, mark); - } - - editor_text_with_selections + self.cx.set_state(text); } pub fn assert_binding( @@ -324,8 +110,8 @@ impl<'a> VimTestContext<'a> { mode_after: Mode, ) { self.set_state(initial_state, initial_mode); - self.simulate_keystrokes(keystrokes); - self.assert_editor_state(state_after); + self.cx.simulate_keystrokes(keystrokes); + self.cx.assert_editor_state(state_after); assert_eq!(self.mode(), mode_after); assert_eq!(self.active_operator(), None); } @@ -337,13 +123,27 @@ impl<'a> VimTestContext<'a> { let mode = self.mode(); VimBindingTestContext::new(keystrokes, mode, mode, self) } + + pub fn assert_clipboard_content(&mut self, expected_content: Option<&str>) { + self.cx.update(|cx| { + let actual_content = cx.read_from_clipboard().map(|item| item.text().to_owned()); + let expected_content = expected_content.map(|content| content.to_owned()); + assert_eq!(actual_content, expected_content); + }) + } } impl<'a> Deref for VimTestContext<'a> { - type Target = gpui::TestAppContext; + type Target = EditorTestContext<'a>; fn deref(&self) -> &Self::Target { - self.cx + &self.cx + } +} + +impl<'a> DerefMut for VimTestContext<'a> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.cx } } @@ -404,3 +204,9 @@ impl<'a, const COUNT: usize> Deref for VimBindingTestContext<'a, COUNT> { &self.cx } } + +impl<'a, const COUNT: usize> DerefMut for VimBindingTestContext<'a, COUNT> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.cx + } +} diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index 4d32d38c300467274f54565add78ab836ca9fd56..3020db5e4c9ef6a621c8f646f9e50ec0657a827f 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -1,14 +1,17 @@ -use editor::{Autoscroll, Bias}; +use collections::HashMap; +use editor::{display_map::ToDisplayPoint, Autoscroll, Bias}; use gpui::{actions, MutableAppContext, ViewContext}; +use language::SelectionGoal; use workspace::Workspace; -use crate::{motion::Motion, state::Mode, Vim}; +use crate::{motion::Motion, state::Mode, utils::copy_selections_content, Vim}; -actions!(vim, [VisualDelete, VisualChange]); +actions!(vim, [VisualDelete, VisualChange, VisualYank]); pub fn init(cx: &mut MutableAppContext) { cx.add_action(change); cx.add_action(delete); + cx.add_action(yank); } pub fn visual_motion(motion: Motion, cx: &mut MutableAppContext) { @@ -17,7 +20,6 @@ pub fn visual_motion(motion: Motion, cx: &mut MutableAppContext) { editor.change_selections(Some(Autoscroll::Fit), cx, |s| { s.move_with(|map, selection| { let (new_head, goal) = motion.move_point(map, selection.head(), selection.goal); - let new_head = map.clip_at_line_end(new_head); let was_reversed = selection.reversed; selection.set_head(new_head, goal); @@ -30,7 +32,7 @@ pub fn visual_motion(motion: Motion, cx: &mut MutableAppContext) { // Head was at the end of the selection, and now is at the start. We need to move the end // forward by one if possible in order to compensate for this change. *selection.end.column_mut() = selection.end.column() + 1; - selection.end = map.clip_point(selection.end, Bias::Left); + selection.end = map.clip_point(selection.end, Bias::Right); } }); }); @@ -42,17 +44,47 @@ pub fn change(_: &mut Workspace, _: &VisualChange, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { - vim.switch_mode(Mode::Normal, cx); vim.update_active_editor(cx, |editor, cx| { editor.set_clip_at_line_ends(false, cx); + let mut original_columns: HashMap<_, _> = Default::default(); + let line_mode = editor.selections.line_mode; editor.change_selections(Some(Autoscroll::Fit), cx, |s| { s.move_with(|map, selection| { - if !selection.reversed { - // Head was at the end of the selection, and now is at the start. We need to move the end - // forward by one if possible in order to compensate for this change. + if line_mode { + original_columns + .insert(selection.id, selection.head().to_point(&map).column); + } else if !selection.reversed { + // Head is at the end of the selection. Adjust the end position to + // to include the character under the cursor. *selection.end.column_mut() = selection.end.column() + 1; - selection.end = map.clip_point(selection.end, Bias::Left); + selection.end = map.clip_point(selection.end, Bias::Right); } + selection.goal = SelectionGoal::None; }); }); + copy_selections_content(editor, line_mode, cx); editor.insert("", cx); // Fixup cursor position after the deletion editor.set_clip_at_line_ends(true, cx); editor.change_selections(Some(Autoscroll::Fit), cx, |s| { s.move_with(|map, selection| { - let mut cursor = selection.head(); - cursor = map.clip_point(cursor, Bias::Left); + let mut cursor = selection.head().to_point(map); + + if let Some(column) = original_columns.get(&selection.id) { + cursor.column = *column + } + let cursor = map.clip_point(cursor.to_display_point(map), Bias::Left); selection.collapse_to(cursor, selection.goal) }); }); }); + vim.switch_mode(Mode::Normal, cx); + }); +} + +pub fn yank(_: &mut Workspace, _: &VisualYank, cx: &mut ViewContext) { + Vim::update(cx, |vim, cx| { + vim.update_active_editor(cx, |editor, cx| { + editor.set_clip_at_line_ends(false, cx); + let line_mode = editor.selections.line_mode; + if !editor.selections.line_mode { + editor.change_selections(None, cx, |s| { + s.move_with(|map, selection| { + if !selection.reversed { + // Head is at the end of the selection. Adjust the end position to + // to include the character under the cursor. + *selection.end.column_mut() = selection.end.column() + 1; + selection.end = map.clip_point(selection.end, Bias::Right); + } + }); + }); + } + copy_selections_content(editor, line_mode, cx); + editor.change_selections(None, cx, |s| { + s.move_with(|_, selection| { + selection.collapse_to(selection.start, SelectionGoal::None) + }); + }); + }); + vim.switch_mode(Mode::Normal, cx); }); } @@ -97,7 +168,9 @@ mod test { #[gpui::test] async fn test_enter_visual_mode(cx: &mut gpui::TestAppContext) { let cx = VimTestContext::new(cx, true).await; - let mut cx = cx.binding(["v", "w", "j"]).mode_after(Mode::Visual); + let mut cx = cx + .binding(["v", "w", "j"]) + .mode_after(Mode::Visual { line: false }); cx.assert( indoc! {" The |quick brown @@ -128,7 +201,9 @@ mod test { fox jumps [over }the lazy dog"}, ); - let mut cx = cx.binding(["v", "b", "k"]).mode_after(Mode::Visual); + let mut cx = cx + .binding(["v", "b", "k"]) + .mode_after(Mode::Visual { line: false }); cx.assert( indoc! {" The |quick brown @@ -176,6 +251,13 @@ mod test { The |ver the lazy dog"}, ); + // Test pasting code copied on delete + cx.simulate_keystrokes(["j", "p"]); + cx.assert_editor_state(indoc! {" + The ver + the l|quick brown + fox jumps oazy dog"}); + cx.assert( indoc! {" The quick brown @@ -226,6 +308,77 @@ mod test { ); } + #[gpui::test] + async fn test_visual_line_delete(cx: &mut gpui::TestAppContext) { + let cx = VimTestContext::new(cx, true).await; + let mut cx = cx.binding(["shift-V", "x"]); + cx.assert( + indoc! {" + The qu|ick brown + fox jumps over + the lazy dog"}, + indoc! {" + fox ju|mps over + the lazy dog"}, + ); + // Test pasting code copied on delete + cx.simulate_keystroke("p"); + cx.assert_editor_state(indoc! {" + fox jumps over + |The quick brown + the lazy dog"}); + + cx.assert( + indoc! {" + The quick brown + fox ju|mps over + the lazy dog"}, + indoc! {" + The quick brown + the la|zy dog"}, + ); + cx.assert( + indoc! {" + The quick brown + fox jumps over + the la|zy dog"}, + indoc! {" + The quick brown + fox ju|mps over"}, + ); + let mut cx = cx.binding(["shift-V", "j", "x"]); + cx.assert( + indoc! {" + The qu|ick brown + fox jumps over + the lazy dog"}, + "the la|zy dog", + ); + // Test pasting code copied on delete + cx.simulate_keystroke("p"); + cx.assert_editor_state(indoc! {" + the lazy dog + |The quick brown + fox jumps over"}); + + cx.assert( + indoc! {" + The quick brown + fox ju|mps over + the lazy dog"}, + "The qu|ick brown", + ); + cx.assert( + indoc! {" + The quick brown + fox jumps over + the la|zy dog"}, + indoc! {" + The quick brown + fox ju|mps over"}, + ); + } + #[gpui::test] async fn test_visual_change(cx: &mut gpui::TestAppContext) { let cx = VimTestContext::new(cx, true).await; @@ -290,4 +443,168 @@ mod test { the lazy dog"}, ); } + + #[gpui::test] + async fn test_visual_line_change(cx: &mut gpui::TestAppContext) { + let cx = VimTestContext::new(cx, true).await; + let mut cx = cx.binding(["shift-V", "c"]).mode_after(Mode::Insert); + cx.assert( + indoc! {" + The qu|ick brown + fox jumps over + the lazy dog"}, + indoc! {" + | + fox jumps over + the lazy dog"}, + ); + // Test pasting code copied on change + cx.simulate_keystrokes(["escape", "j", "p"]); + cx.assert_editor_state(indoc! {" + + fox jumps over + |The quick brown + the lazy dog"}); + + cx.assert( + indoc! {" + The quick brown + fox ju|mps over + the lazy dog"}, + indoc! {" + The quick brown + | + the lazy dog"}, + ); + cx.assert( + indoc! {" + The quick brown + fox jumps over + the la|zy dog"}, + indoc! {" + The quick brown + fox jumps over + |"}, + ); + let mut cx = cx.binding(["shift-V", "j", "c"]).mode_after(Mode::Insert); + cx.assert( + indoc! {" + The qu|ick brown + fox jumps over + the lazy dog"}, + indoc! {" + | + the lazy dog"}, + ); + // Test pasting code copied on delete + cx.simulate_keystrokes(["escape", "j", "p"]); + cx.assert_editor_state(indoc! {" + + the lazy dog + |The quick brown + fox jumps over"}); + cx.assert( + indoc! {" + The quick brown + fox ju|mps over + the lazy dog"}, + indoc! {" + The quick brown + |"}, + ); + cx.assert( + indoc! {" + The quick brown + fox jumps over + the la|zy dog"}, + indoc! {" + The quick brown + fox jumps over + |"}, + ); + } + + #[gpui::test] + async fn test_visual_yank(cx: &mut gpui::TestAppContext) { + let cx = VimTestContext::new(cx, true).await; + let mut cx = cx.binding(["v", "w", "y"]); + cx.assert("The quick |brown", "The quick |brown"); + cx.assert_clipboard_content(Some("brown")); + let mut cx = cx.binding(["v", "w", "j", "y"]); + cx.assert( + indoc! {" + The |quick brown + fox jumps over + the lazy dog"}, + indoc! {" + The |quick brown + fox jumps over + the lazy dog"}, + ); + cx.assert_clipboard_content(Some(indoc! {" + quick brown + fox jumps o"})); + cx.assert( + indoc! {" + The quick brown + fox jumps over + the |lazy dog"}, + indoc! {" + The quick brown + fox jumps over + the |lazy dog"}, + ); + cx.assert_clipboard_content(Some("lazy d")); + cx.assert( + indoc! {" + The quick brown + fox jumps |over + the lazy dog"}, + indoc! {" + The quick brown + fox jumps |over + the lazy dog"}, + ); + cx.assert_clipboard_content(Some(indoc! {" + over + t"})); + let mut cx = cx.binding(["v", "b", "k", "y"]); + cx.assert( + indoc! {" + The |quick brown + fox jumps over + the lazy dog"}, + indoc! {" + |The quick brown + fox jumps over + the lazy dog"}, + ); + cx.assert_clipboard_content(Some("The q")); + cx.assert( + indoc! {" + The quick brown + fox jumps over + the |lazy dog"}, + indoc! {" + The quick brown + |fox jumps over + the lazy dog"}, + ); + cx.assert_clipboard_content(Some(indoc! {" + fox jumps over + the l"})); + cx.assert( + indoc! {" + The quick brown + fox jumps |over + the lazy dog"}, + indoc! {" + The |quick brown + fox jumps over + the lazy dog"}, + ); + cx.assert_clipboard_content(Some(indoc! {" + quick brown + fox jumps o"})); + } } diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index db157ed8503ef619b202c21de86f12e52da13047..e8e67642739224048ded305f5d970682b778c13a 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -15,7 +15,7 @@ use gpui::{ use project::{Project, ProjectEntryId, ProjectPath}; use serde::Deserialize; use settings::Settings; -use std::{any::Any, cell::RefCell, cmp, mem, path::Path, rc::Rc}; +use std::{any::Any, cell::RefCell, mem, path::Path, rc::Rc}; use util::ResultExt; actions!( @@ -109,6 +109,7 @@ pub enum Event { ActivateItem { local: bool }, Remove, Split(SplitDirection), + ChangeItemTitle, } pub struct Pane { @@ -334,9 +335,20 @@ impl Pane { item.set_nav_history(pane.read(cx).nav_history.clone(), cx); item.added_to_pane(workspace, pane.clone(), cx); pane.update(cx, |pane, cx| { - let item_idx = cmp::min(pane.active_item_index + 1, pane.items.len()); - pane.items.insert(item_idx, item); - pane.activate_item(item_idx, activate_pane, focus_item, cx); + // If there is already an active item, then insert the new item + // right after it. Otherwise, adjust the `active_item_index` field + // before activating the new item, so that in the `activate_item` + // method, we can detect that the active item is changing. + let item_ix; + if pane.active_item_index < pane.items.len() { + item_ix = pane.active_item_index + 1 + } else { + item_ix = pane.items.len(); + pane.active_item_index = usize::MAX; + }; + + pane.items.insert(item_ix, item); + pane.activate_item(item_ix, activate_pane, focus_item, cx); cx.notify(); }); } @@ -383,11 +395,12 @@ impl Pane { use NavigationMode::{GoingBack, GoingForward}; if index < self.items.len() { let prev_active_item_ix = mem::replace(&mut self.active_item_index, index); - if matches!(self.nav_history.borrow().mode, GoingBack | GoingForward) - || (prev_active_item_ix != self.active_item_index - && prev_active_item_ix < self.items.len()) + if prev_active_item_ix != self.active_item_index + || matches!(self.nav_history.borrow().mode, GoingBack | GoingForward) { - self.items[prev_active_item_ix].deactivated(cx); + if let Some(prev_item) = self.items.get(prev_active_item_ix) { + prev_item.deactivated(cx); + } cx.emit(Event::ActivateItem { local: activate_pane, }); @@ -424,7 +437,7 @@ impl Pane { self.activate_item(index, true, true, cx); } - fn close_active_item( + pub fn close_active_item( workspace: &mut Workspace, _: &CloseActiveItem, cx: &mut ViewContext, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 21361b80816dbd0b809bedae2e13d0a6d98b43b7..2c77c72f1379611f0664f0aa725e62c92b8d9499 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -37,6 +37,7 @@ use status_bar::StatusBar; pub use status_bar::StatusItemView; use std::{ any::{Any, TypeId}, + borrow::Cow, cell::RefCell, fmt, future::Future, @@ -543,7 +544,10 @@ impl ItemHandle for ViewHandle { } if T::should_update_tab_on_event(event) { - pane.update(cx, |_, cx| cx.notify()); + pane.update(cx, |_, cx| { + cx.emit(pane::Event::ChangeItemTitle); + cx.notify(); + }); } }) .detach(); @@ -755,6 +759,9 @@ impl Workspace { project::Event::CollaboratorLeft(peer_id) => { this.collaborator_left(*peer_id, cx); } + project::Event::WorktreeRemoved(_) | project::Event::WorktreeAdded => { + this.update_window_title(cx); + } _ => {} } if project.read(cx).is_read_only() { @@ -766,14 +773,8 @@ impl Workspace { let pane = cx.add_view(|cx| Pane::new(cx)); let pane_id = pane.id(); - cx.observe(&pane, move |me, _, cx| { - let active_entry = me.active_project_path(cx); - me.project - .update(cx, |project, cx| project.set_active_path(active_entry, cx)); - }) - .detach(); - cx.subscribe(&pane, move |me, _, event, cx| { - me.handle_pane_event(pane_id, event, cx) + cx.subscribe(&pane, move |this, _, event, cx| { + this.handle_pane_event(pane_id, event, cx) }) .detach(); cx.focus(&pane); @@ -836,6 +837,11 @@ impl Workspace { _observe_current_user, }; this.project_remote_id_changed(this.project.read(cx).remote_id(), cx); + + cx.defer(|this, cx| { + this.update_window_title(cx); + }); + this } @@ -1258,14 +1264,8 @@ impl Workspace { fn add_pane(&mut self, cx: &mut ViewContext) -> ViewHandle { let pane = cx.add_view(|cx| Pane::new(cx)); let pane_id = pane.id(); - cx.observe(&pane, move |me, _, cx| { - let active_entry = me.active_project_path(cx); - me.project - .update(cx, |project, cx| project.set_active_path(active_entry, cx)); - }) - .detach(); - cx.subscribe(&pane, move |me, _, event, cx| { - me.handle_pane_event(pane_id, event, cx) + cx.subscribe(&pane, move |this, _, event, cx| { + this.handle_pane_event(pane_id, event, cx) }) .detach(); self.panes.push(pane.clone()); @@ -1405,6 +1405,7 @@ impl Workspace { self.status_bar.update(cx, |status_bar, cx| { status_bar.set_active_pane(&self.active_pane, cx); }); + self.active_item_path_changed(cx); cx.focus(&self.active_pane); cx.notify(); } @@ -1439,6 +1440,14 @@ impl Workspace { if *local { self.unfollow(&pane, cx); } + if pane == self.active_pane { + self.active_item_path_changed(cx); + } + } + pane::Event::ChangeItemTitle => { + if pane == self.active_pane { + self.active_item_path_changed(cx); + } } } } else { @@ -1471,6 +1480,8 @@ impl Workspace { self.unfollow(&pane, cx); self.last_leaders_by_pane.remove(&pane.downgrade()); cx.notify(); + } else { + self.active_item_path_changed(cx); } } @@ -1658,15 +1669,7 @@ impl Workspace { fn render_titlebar(&self, theme: &Theme, cx: &mut RenderContext) -> ElementBox { let mut worktree_root_names = String::new(); - { - let mut worktrees = self.project.read(cx).visible_worktrees(cx).peekable(); - while let Some(worktree) = worktrees.next() { - worktree_root_names.push_str(worktree.read(cx).root_name()); - if worktrees.peek().is_some() { - worktree_root_names.push_str(", "); - } - } - } + self.worktree_root_names(&mut worktree_root_names, cx); ConstrainedBox::new( Container::new( @@ -1702,6 +1705,50 @@ impl Workspace { .named("titlebar") } + fn active_item_path_changed(&mut self, cx: &mut ViewContext) { + let active_entry = self.active_project_path(cx); + self.project + .update(cx, |project, cx| project.set_active_path(active_entry, cx)); + self.update_window_title(cx); + } + + fn update_window_title(&mut self, cx: &mut ViewContext) { + let mut title = String::new(); + if let Some(path) = self.active_item(cx).and_then(|item| item.project_path(cx)) { + let filename = path + .path + .file_name() + .map(|s| s.to_string_lossy()) + .or_else(|| { + Some(Cow::Borrowed( + self.project() + .read(cx) + .worktree_for_id(path.worktree_id, cx)? + .read(cx) + .root_name(), + )) + }); + if let Some(filename) = filename { + title.push_str(filename.as_ref()); + title.push_str(" — "); + } + } + self.worktree_root_names(&mut title, cx); + if title.is_empty() { + title = "empty project".to_string(); + } + cx.set_window_title(&title); + } + + fn worktree_root_names(&self, string: &mut String, cx: &mut MutableAppContext) { + for (i, worktree) in self.project.read(cx).visible_worktrees(cx).enumerate() { + if i != 0 { + string.push_str(", "); + } + string.push_str(worktree.read(cx).root_name()); + } + } + fn render_collaborators(&self, theme: &Theme, cx: &mut RenderContext) -> Vec { let mut collaborators = self .project @@ -2437,6 +2484,110 @@ mod tests { use project::{FakeFs, Project, ProjectEntryId}; use serde_json::json; + #[gpui::test] + async fn test_tracking_active_path(cx: &mut TestAppContext) { + cx.foreground().forbid_parking(); + Settings::test_async(cx); + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/root1", + json!({ + "one.txt": "", + "two.txt": "", + }), + ) + .await; + fs.insert_tree( + "/root2", + json!({ + "three.txt": "", + }), + ) + .await; + + let project = Project::test(fs, ["root1".as_ref()], cx).await; + let (window_id, workspace) = cx.add_window(|cx| Workspace::new(project.clone(), cx)); + let worktree_id = project.read_with(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); + + let item1 = cx.add_view(window_id, |_| { + let mut item = TestItem::new(); + item.project_path = Some((worktree_id, "one.txt").into()); + item + }); + let item2 = cx.add_view(window_id, |_| { + let mut item = TestItem::new(); + item.project_path = Some((worktree_id, "two.txt").into()); + item + }); + + // Add an item to an empty pane + workspace.update(cx, |workspace, cx| workspace.add_item(Box::new(item1), cx)); + project.read_with(cx, |project, cx| { + assert_eq!( + project.active_entry(), + project.entry_for_path(&(worktree_id, "one.txt").into(), cx) + ); + }); + assert_eq!( + cx.current_window_title(window_id).as_deref(), + Some("one.txt — root1") + ); + + // Add a second item to a non-empty pane + workspace.update(cx, |workspace, cx| workspace.add_item(Box::new(item2), cx)); + assert_eq!( + cx.current_window_title(window_id).as_deref(), + Some("two.txt — root1") + ); + project.read_with(cx, |project, cx| { + assert_eq!( + project.active_entry(), + project.entry_for_path(&(worktree_id, "two.txt").into(), cx) + ); + }); + + // Close the active item + workspace + .update(cx, |workspace, cx| { + Pane::close_active_item(workspace, &Default::default(), cx).unwrap() + }) + .await + .unwrap(); + assert_eq!( + cx.current_window_title(window_id).as_deref(), + Some("one.txt — root1") + ); + project.read_with(cx, |project, cx| { + assert_eq!( + project.active_entry(), + project.entry_for_path(&(worktree_id, "one.txt").into(), cx) + ); + }); + + // Add a project folder + project + .update(cx, |project, cx| { + project.find_or_create_local_worktree("/root2", true, cx) + }) + .await + .unwrap(); + assert_eq!( + cx.current_window_title(window_id).as_deref(), + Some("one.txt — root1, root2") + ); + + // Remove a project folder + project.update(cx, |project, cx| { + project.remove_worktree(worktree_id, cx); + }); + assert_eq!( + cx.current_window_title(window_id).as_deref(), + Some("one.txt — root2") + ); + } + #[gpui::test] async fn test_close_window(cx: &mut TestAppContext) { cx.foreground().forbid_parking(); @@ -2476,18 +2627,6 @@ mod tests { cx.foreground().run_until_parked(); assert!(!cx.has_pending_prompt(window_id)); assert_eq!(task.await.unwrap(), false); - - // If there are multiple dirty items representing the same project entry. - workspace.update(cx, |w, cx| { - w.add_item(Box::new(item2.clone()), cx); - w.add_item(Box::new(item3.clone()), cx); - }); - let task = workspace.update(cx, |w, cx| w.prepare_to_close(cx)); - cx.foreground().run_until_parked(); - cx.simulate_prompt_answer(window_id, 2 /* cancel */); - cx.foreground().run_until_parked(); - assert!(!cx.has_pending_prompt(window_id)); - assert_eq!(task.await.unwrap(), false); } #[gpui::test] @@ -2687,6 +2826,7 @@ mod tests { is_dirty: bool, has_conflict: bool, project_entry_ids: Vec, + project_path: Option, is_singleton: bool, } @@ -2699,6 +2839,7 @@ mod tests { is_dirty: false, has_conflict: false, project_entry_ids: Vec::new(), + project_path: None, is_singleton: true, } } @@ -2724,7 +2865,7 @@ mod tests { } fn project_path(&self, _: &AppContext) -> Option { - None + self.project_path.clone() } fn project_entry_ids(&self, _: &AppContext) -> SmallVec<[ProjectEntryId; 3]> { @@ -2783,5 +2924,9 @@ mod tests { self.reload_count += 1; Task::ready(Ok(())) } + + fn should_update_tab_on_event(_: &Self::Event) -> bool { + true + } } } diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 04a4f9aada48921935339bc744ee6b52c3090be1..821b1e2ebcbf777e5f6c8e60c51bea88f5961d9e 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -180,8 +180,8 @@ fn main() { cx.observe_global::({ let languages = languages.clone(); - move |settings, _| { - languages.set_theme(&settings.theme.editor.syntax); + move |cx| { + languages.set_theme(&cx.global::().theme.editor.syntax); } }) .detach(); diff --git a/crates/zed/src/menus.rs b/crates/zed/src/menus.rs index cfe4ca082688b37ac73d720cfd47d5e8a32c4cd2..cc5f128bc014462e77400f7a3ade0c0f1e0503ad 100644 --- a/crates/zed/src/menus.rs +++ b/crates/zed/src/menus.rs @@ -15,6 +15,14 @@ pub fn menus() -> Vec> { action: Box::new(auto_update::Check), }, MenuItem::Separator, + MenuItem::Action { + name: "Open Settings", + action: Box::new(super::OpenSettings), + }, + MenuItem::Action { + name: "Open Key Bindings", + action: Box::new(super::OpenKeymap), + }, MenuItem::Action { name: "Install CLI", action: Box::new(super::InstallCommandLineInterface), @@ -164,6 +172,10 @@ pub fn menus() -> Vec> { name: "Zoom Out", action: Box::new(super::DecreaseBufferFontSize), }, + MenuItem::Action { + name: "Reset Zoom", + action: Box::new(super::ResetBufferFontSize), + }, MenuItem::Separator, MenuItem::Action { name: "Project Browser", @@ -229,12 +241,31 @@ pub fn menus() -> Vec> { }, ], }, + Menu { + name: "Window", + items: vec![MenuItem::Separator], + }, Menu { name: "Help", - items: vec![MenuItem::Action { - name: "Command Palette", - action: Box::new(command_palette::Toggle), - }], + items: vec![ + MenuItem::Action { + name: "Command Palette", + action: Box::new(command_palette::Toggle), + }, + MenuItem::Separator, + MenuItem::Action { + name: "Zed.dev", + action: Box::new(crate::OpenBrowser { + url: "https://zed.dev".into(), + }), + }, + MenuItem::Action { + name: "Zed Twitter", + action: Box::new(crate::OpenBrowser { + url: "https://twitter.com/zeddotdev".into(), + }), + }, + ], }, ] } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index c2f6c60ea673a48b90e1d38c65648ca489615d86..6ebe3dc35de65855b4efe58fc06000dbfc73bf1c 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -14,6 +14,7 @@ use editor::Editor; use gpui::{ actions, geometry::vector::vec2f, + impl_actions, platform::{WindowBounds, WindowOptions}, AsyncAppContext, ViewContext, }; @@ -23,6 +24,7 @@ use project::Project; pub use project::{self, fs}; use project_panel::ProjectPanel; use search::{BufferSearchBar, ProjectSearchBar}; +use serde::Deserialize; use serde_json::to_string_pretty; use settings::{keymap_file_json_schema, settings_file_json_schema, Settings}; use std::{ @@ -33,6 +35,13 @@ use util::ResultExt; pub use workspace; use workspace::{AppState, Workspace}; +#[derive(Deserialize, Clone)] +struct OpenBrowser { + url: Arc, +} + +impl_actions!(zed, [OpenBrowser]); + actions!( zed, [ @@ -43,6 +52,7 @@ actions!( OpenKeymap, IncreaseBufferFontSize, DecreaseBufferFontSize, + ResetBufferFontSize, InstallCommandLineInterface, ] ); @@ -60,6 +70,7 @@ lazy_static! { pub fn init(app_state: &Arc, cx: &mut gpui::MutableAppContext) { cx.add_action(about); cx.add_global_action(quit); + cx.add_global_action(move |action: &OpenBrowser, cx| cx.platform().open_url(&action.url)); cx.add_global_action(move |_: &IncreaseBufferFontSize, cx| { cx.update_global::(|settings, cx| { settings.buffer_font_size = (settings.buffer_font_size + 1.0).max(MIN_FONT_SIZE); @@ -72,6 +83,12 @@ pub fn init(app_state: &Arc, cx: &mut gpui::MutableAppContext) { cx.refresh_windows(); }); }); + cx.add_global_action(move |_: &ResetBufferFontSize, cx| { + cx.update_global::(|settings, cx| { + settings.buffer_font_size = settings.default_buffer_font_size; + cx.refresh_windows(); + }); + }); cx.add_global_action(move |_: &InstallCommandLineInterface, cx| { cx.spawn(|cx| async move { install_cli(&cx).await.context("error creating CLI symlink") }) .detach_and_log_err(cx);