From 125d83b3ec96bf6f87779373f759b3611deb38c3 Mon Sep 17 00:00:00 2001 From: Keith Simmons Date: Thu, 26 May 2022 15:40:46 -0700 Subject: [PATCH 01/26] Fix failing seed bin build and add bin builds to ci pipeline --- .github/workflows/ci.yml | 3 ++ crates/collab/src/bin/seed.rs | 19 +++++----- crates/collab/src/lib.rs | 69 ++++++++++++++++++++++++++++++++++ crates/collab/src/main.rs | 71 +---------------------------------- 4 files changed, 84 insertions(+), 78 deletions(-) create mode 100644 crates/collab/src/lib.rs 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/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/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 74401699ca62afbc23273f667494258d955af9e9..47f1e6173a3b740b5430a5e73d765efdc7871129 100644 --- a/crates/collab/src/main.rs +++ b/crates/collab/src/main.rs @@ -4,7 +4,8 @@ mod db; mod env; mod rpc; -use axum::{body::Body, http::StatusCode, response::IntoResponse, Router}; +use axum::{body::Body, Router}; +use collab::{Error, Result}; use db::{Db, PostgresDb}; use serde::Deserialize; use std::{ @@ -73,74 +74,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; From d7d17b21487dfccda90d9d9f23e194f8d6cef616 Mon Sep 17 00:00:00 2001 From: Keith Simmons Date: Wed, 18 May 2022 11:10:24 -0700 Subject: [PATCH 02/26] WIP line mode operations --- assets/keymaps/vim.json | 12 ++++ crates/editor/src/editor.rs | 12 +++- crates/editor/src/element.rs | 17 +++--- crates/editor/src/items.rs | 6 +- crates/editor/src/multi_buffer.rs | 8 ++- crates/editor/src/selections_collection.rs | 2 + crates/language/src/buffer.rs | 27 +++++++-- crates/language/src/proto.rs | 3 + crates/language/src/tests.rs | 4 +- crates/rpc/proto/zed.proto | 2 + crates/vim/src/editor_events.rs | 29 +++++++--- crates/vim/src/motion.rs | 1 + crates/vim/src/state.rs | 5 +- crates/vim/src/vim.rs | 3 +- crates/vim/src/visual.rs | 65 +++++++++++++++++++++- 15 files changed, 166 insertions(+), 30 deletions(-) diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index fc13d2927c856c4125fcd0f227adbc044abd0b09..dd2e6a132a58ab4ac532e7bf899d2036a3274800 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -72,6 +72,10 @@ "v": [ "vim::SwitchMode", "Visual" + ], + "V": [ + "vim::SwitchMode", + "VisualLine" ] } }, @@ -112,6 +116,14 @@ "x": "vim::VisualDelete" } }, + { + "context": "Editor && vim_mode == visual_line", + "bindings": { + "c": "vim::VisualLineChange", + "d": "vim::VisualLineDelete", + "x": "vim::VisualLineDelete" + } + }, { "context": "Editor && vim_mode == insert", "bindings": { diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index e5a80e44f4014127cb5cb76090bfd04a1092e65b..a4761ddb060d42eee902b06909152f2cb8b26594 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1319,7 +1319,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, + ) }); } @@ -5599,7 +5603,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, + ); } }); } diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 355d1f44337c9c855625ecc0255e51b57ee5db4b..9893f942924c70f4f5232ddfe138096f409ff701 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -345,12 +345,13 @@ impl EditorElement { scroll_top, scroll_left, bounds, + false, cx, ); } let mut cursors = SmallVec::<[Cursor; 32]>::new(); - for (replica_id, selections) in &layout.selections { + for ((replica_id, line_mode), selections) in &layout.selections { let selection_style = style.replica_selection_style(*replica_id); let corner_radius = 0.15 * layout.line_height; @@ -367,6 +368,7 @@ impl EditorElement { scroll_top, scroll_left, bounds, + *line_mode, cx, ); @@ -483,6 +485,7 @@ impl EditorElement { scroll_top: f32, scroll_left: f32, bounds: RectF, + line_mode: bool, cx: &mut PaintContext, ) { if range.start != range.end { @@ -503,14 +506,14 @@ impl EditorElement { .map(|row| { let line_layout = &layout.line_layouts[(row - start_row) as usize]; HighlightedRangeLine { - start_x: if row == range.start.row() { + start_x: if row == range.start.row() && !line_mode { content_origin.x() + line_layout.x_for_index(range.start.column() as usize) - scroll_left } else { content_origin.x() - scroll_left }, - end_x: if row == range.end.row() { + end_x: if row == range.end.row() && !line_mode { content_origin.x() + line_layout.x_for_index(range.end.column() as usize) - scroll_left @@ -934,7 +937,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())) { @@ -944,7 +947,7 @@ impl Element for EditorElement { } remote_selections - .entry(replica_id) + .entry((replica_id, line_mode)) .or_insert(Vec::new()) .push(crate::Selection { id: selection.id, @@ -978,7 +981,7 @@ impl Element for EditorElement { let local_replica_id = view.leader_replica_id.unwrap_or(view.replica_id(cx)); selections.push(( - local_replica_id, + (local_replica_id, view.selections.line_mode), local_selections .into_iter() .map(|selection| crate::Selection { @@ -1237,7 +1240,7 @@ pub struct LayoutState { em_width: f32, em_advance: f32, highlighted_ranges: Vec<(Range, Color)>, - selections: Vec<(ReplicaId, Vec>)>, + selections: Vec<((ReplicaId, bool), 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..7d9ac8ed4025478192c4ab1e98ab6f19dff3caa3 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: true, disjoint: Arc::from([]), pending: Some(PendingSelection { selection: Selection { 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/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index 0fee451c0d6a64df089592bc50a0ce9172e05ef7..91935f2be61f219f7f76390637eca65973b5ed2d 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -779,6 +779,7 @@ message SelectionSet { uint32 replica_id = 1; repeated Selection selections = 2; uint32 lamport_timestamp = 3; + bool line_mode = 4; } message Selection { @@ -854,6 +855,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/vim/src/editor_events.rs b/crates/vim/src/editor_events.rs index f9dfc588e12a0c9536c174ec3096d879f1ebf4b4..f3b6115c841d0fda9c6474c445d14248468b0768 100644 --- a/crates/vim/src/editor_events.rs +++ b/crates/vim/src/editor_events.rs @@ -18,22 +18,29 @@ 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 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 +54,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.state.mode == Mode::Normal && !newest_empty { + vim.switch_mode(Mode::Visual, cx) + } + }) +} diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index a38d10c8f8be4fc1d701ef12480afd4db0ab54e8..8ab485b58c68789587f66deaf06c70251f850ae0 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -112,6 +112,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::VisualLine => visual_motion(motion, cx), Mode::Insert => { // Shouldn't execute a motion in insert mode. Ignoring } diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index b4d5cbe9c7e2f19b7e611ed6849d6bbae54cddbe..31c2336f5e4721ce156bb71600719566fd0cb10e 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -7,6 +7,7 @@ pub enum Mode { Normal, Insert, Visual, + VisualLine, } impl Default for Mode { @@ -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 | Mode::VisualLine => CursorShape::Block, Mode::Insert => CursorShape::Bar, } } @@ -53,6 +53,7 @@ impl VimState { match self.mode { Mode::Normal => "normal", Mode::Visual => "visual", + Mode::VisualLine => "visual_line", Mode::Insert => "insert", } .to_string(), diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index f0731edd49b5cd3bfa14fa34f12899ae64920a99..115ef9ea38d1a8fb5b0737fee62404576a497bce 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -10,7 +10,7 @@ mod visual; use collections::HashMap; use editor::{CursorShape, Editor}; -use gpui::{impl_actions, MutableAppContext, ViewContext, WeakViewHandle}; +use gpui::{impl_actions, MutableAppContext, Subscription, ViewContext, WeakViewHandle}; use serde::Deserialize; use settings::Settings; @@ -51,6 +51,7 @@ pub fn init(cx: &mut MutableAppContext) { pub struct Vim { editors: HashMap>, active_editor: Option>, + selection_subscription: Option, enabled: bool, state: VimState, diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index 4d32d38c300467274f54565add78ab836ca9fd56..da9bc04cb1e4bd0fb51d58871130e05665923d0b 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -4,11 +4,21 @@ use workspace::Workspace; use crate::{motion::Motion, state::Mode, Vim}; -actions!(vim, [VisualDelete, VisualChange]); +actions!( + vim, + [ + VisualDelete, + VisualChange, + VisualLineDelete, + VisualLineChange + ] +); pub fn init(cx: &mut MutableAppContext) { cx.add_action(change); + cx.add_action(change_line); cx.add_action(delete); + cx.add_action(delete_line); } pub fn visual_motion(motion: Motion, cx: &mut MutableAppContext) { @@ -58,6 +68,22 @@ pub fn change(_: &mut Workspace, _: &VisualChange, cx: &mut ViewContext) { + Vim::update(cx, |vim, cx| { + vim.update_active_editor(cx, |editor, cx| { + editor.set_clip_at_line_ends(false, cx); + editor.change_selections(Some(Autoscroll::Fit), cx, |s| { + s.move_with(|map, selection| { + selection.start = map.prev_line_boundary(selection.start.to_point(map)).1; + selection.end = map.next_line_boundary(selection.end.to_point(map)).1; + }); + }); + editor.insert("", cx); + }); + vim.switch_mode(Mode::Insert, cx); + }); +} + pub fn delete(_: &mut Workspace, _: &VisualDelete, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { vim.switch_mode(Mode::Normal, cx); @@ -88,6 +114,43 @@ pub fn delete(_: &mut Workspace, _: &VisualDelete, 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); + editor.change_selections(Some(Autoscroll::Fit), cx, |s| { + s.move_with(|map, selection| { + selection.start = map.prev_line_boundary(selection.start.to_point(map)).1; + + if selection.end.row() < map.max_point().row() { + *selection.end.row_mut() += 1; + *selection.end.column_mut() = 0; + // 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.end = map.next_line_boundary(selection.end.to_point(map)).1; + }); + }); + 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); + selection.collapse_to(cursor, selection.goal) + }); + }); + }); + }); +} + #[cfg(test)] mod test { use indoc::indoc; From f8f316cc64b9f2b7cbfe225c92fe7713c6020f84 Mon Sep 17 00:00:00 2001 From: Keith Simmons Date: Wed, 18 May 2022 17:41:26 -0700 Subject: [PATCH 03/26] Working change and delete in line mode --- assets/keymaps/vim.json | 2 +- crates/editor/src/element.rs | 2 +- crates/editor/src/selections_collection.rs | 2 +- crates/vim/src/editor_events.rs | 2 +- crates/vim/src/state.rs | 4 ++++ crates/vim/src/vim.rs | 10 ++++++++++ crates/vim/src/visual.rs | 12 +++++++++--- 7 files changed, 27 insertions(+), 7 deletions(-) diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index dd2e6a132a58ab4ac532e7bf899d2036a3274800..e5fdf44d3ea5238cade39d72107807854393fb9f 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -73,7 +73,7 @@ "vim::SwitchMode", "Visual" ], - "V": [ + "shift-V": [ "vim::SwitchMode", "VisualLine" ] diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 9893f942924c70f4f5232ddfe138096f409ff701..a794ac7edd9725d84c5fed4ca3aede5062f66fd7 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -488,7 +488,7 @@ impl EditorElement { line_mode: bool, cx: &mut PaintContext, ) { - if range.start != range.end { + if range.start != range.end || line_mode { let row_range = if range.end.column() == 0 { cmp::max(range.start.row(), start_row)..cmp::min(range.end.row(), end_row) } else { diff --git a/crates/editor/src/selections_collection.rs b/crates/editor/src/selections_collection.rs index 7d9ac8ed4025478192c4ab1e98ab6f19dff3caa3..dfed550777247fdf1ea73e6d69c82411b88cff64 100644 --- a/crates/editor/src/selections_collection.rs +++ b/crates/editor/src/selections_collection.rs @@ -38,7 +38,7 @@ impl SelectionsCollection { display_map, buffer, next_selection_id: 1, - line_mode: true, + line_mode: false, disjoint: Arc::from([]), pending: Some(PendingSelection { selection: Selection { diff --git a/crates/vim/src/editor_events.rs b/crates/vim/src/editor_events.rs index f3b6115c841d0fda9c6474c445d14248468b0768..1d477313846033fe2eed1d34a215a06c8e87fd68 100644 --- a/crates/vim/src/editor_events.rs +++ b/crates/vim/src/editor_events.rs @@ -22,7 +22,7 @@ fn editor_focused(EditorFocused(editor): &EditorFocused, cx: &mut MutableAppCont vim.active_editor = Some(editor.downgrade()); vim.selection_subscription = Some(cx.subscribe(editor, |editor, event, cx| { if let editor::Event::SelectionsChanged { local: true } = event { - let newest_empty = !editor.read(cx).selections.newest::(cx).is_empty(); + let newest_empty = editor.read(cx).selections.newest::(cx).is_empty(); editor_local_selections_changed(newest_empty, cx); } })); diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index 31c2336f5e4721ce156bb71600719566fd0cb10e..a5ae5448fb73f8bda9692858ceff215ea460e95d 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -46,6 +46,10 @@ impl VimState { !matches!(self.mode, Mode::Insert) } + pub fn empty_selections_only(&self) -> bool { + self.mode != Mode::Visual && self.mode != Mode::VisualLine + } + pub fn keymap_context_layer(&self) -> Context { let mut context = Context::default(); context.map.insert( diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 115ef9ea38d1a8fb5b0737fee62404576a497bce..115536e6a5ad014f57bec88b50d2549241b49b7a 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -128,14 +128,24 @@ impl Vim { editor.set_cursor_shape(cursor_shape, cx); editor.set_clip_at_line_ends(cursor_shape == CursorShape::Block, cx); editor.set_input_enabled(!state.vim_controlled()); + editor.selections.line_mode = state.mode == Mode::VisualLine; 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::(); } + + if state.empty_selections_only() { + editor.change_selections(None, cx, |s| { + s.move_with(|_, selection| { + selection.collapse_to(selection.head(), selection.goal) + }); + }) + } }); } } diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index da9bc04cb1e4bd0fb51d58871130e05665923d0b..0a7517bfb8078687a8b4a4db08582e967cdc15a7 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -1,3 +1,4 @@ +use collections::HashMap; use editor::{Autoscroll, Bias}; use gpui::{actions, MutableAppContext, ViewContext}; use workspace::Workspace; @@ -68,7 +69,7 @@ pub fn change(_: &mut Workspace, _: &VisualChange, cx: &mut ViewContext) { +pub fn change_line(_: &mut Workspace, _: &VisualLineChange, cx: &mut ViewContext) { Vim::update(cx, |vim, cx| { vim.update_active_editor(cx, |editor, cx| { editor.set_clip_at_line_ends(false, cx); @@ -114,13 +115,14 @@ pub fn delete(_: &mut Workspace, _: &VisualDelete, cx: &mut ViewContext) { +pub fn delete_line(_: &mut Workspace, _: &VisualLineDelete, 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(); editor.change_selections(Some(Autoscroll::Fit), cx, |s| { s.move_with(|map, selection| { + original_columns.insert(selection.id, selection.head().column()); selection.start = map.prev_line_boundary(selection.start.to_point(map)).1; if selection.end.row() < map.max_point().row() { @@ -143,11 +145,15 @@ pub fn delete_line(_: &mut Workspace, _: &VisualChange, cx: &mut ViewContext Date: Thu, 19 May 2022 10:25:06 -0700 Subject: [PATCH 04/26] WIP copy on delete --- crates/editor/src/editor.rs | 6 +++--- crates/vim/src/normal/change.rs | 18 ++++++++++++++++-- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index a4761ddb060d42eee902b06909152f2cb8b26594..219fcba22b7c23e4a3f25f2d13557b4466d4386f 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -837,9 +837,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)] diff --git a/crates/vim/src/normal/change.rs b/crates/vim/src/normal/change.rs index 8124f8a2006c974d8dace98648d7128c8d881aa2..0636b4b1ef7fb1fb5002a4d9d5f71fdcdcdae9ff 100644 --- a/crates/vim/src/normal/change.rs +++ b/crates/vim/src/normal/change.rs @@ -1,6 +1,6 @@ use crate::{motion::Motion, state::Mode, Vim}; -use editor::{char_kind, movement, Autoscroll}; -use gpui::{impl_actions, MutableAppContext, ViewContext}; +use editor::{char_kind, movement, Autoscroll, ClipboardSelection}; +use gpui::{impl_actions, ClipboardItem, MutableAppContext, ViewContext}; use serde::Deserialize; use workspace::Workspace; @@ -22,12 +22,26 @@ pub fn change_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) { editor.transact(cx, |editor, cx| { // We are swapping to insert mode anyway. Just set the line end clipping behavior now editor.set_clip_at_line_ends(false, cx); + let mut text = String::new(); + let buffer = editor.buffer().read(cx).snapshot(cx); + let mut clipboard_selections = Vec::with_capacity(editor.selections.count()); editor.change_selections(Some(Autoscroll::Fit), cx, |s| { s.move_with(|map, selection| { motion.expand_selection(map, selection, false); + let mut len = 0; + let range = selection.start.to_point(map)..selection.end.to_point(map); + for chunk in buffer.text_for_range(range) { + text.push_str(chunk); + len += chunk.len(); + } + clipboard_selections.push(ClipboardSelection { + len, + is_entire_line: motion.linewise(), + }); }); }); editor.insert(&"", cx); + cx.write_to_clipboard(ClipboardItem::new(text).with_metadata(clipboard_selections)); }); }); vim.switch_mode(Mode::Insert, cx) From 082036161fd3815c831ceedfd28ba15b0ed6eb9f Mon Sep 17 00:00:00 2001 From: Keith Simmons Date: Thu, 19 May 2022 17:42:30 -0700 Subject: [PATCH 05/26] Enable copy and paste in vim mode --- assets/keymaps/vim.json | 3 +- crates/editor/src/editor.rs | 2 +- crates/editor/src/element.rs | 2 +- crates/text/src/selection.rs | 5 ++ crates/vim/src/normal.rs | 124 +++++++++++++++++++++++++++++++- crates/vim/src/normal/change.rs | 22 ++---- crates/vim/src/normal/delete.rs | 3 +- crates/vim/src/utils.rs | 26 +++++++ crates/vim/src/vim.rs | 14 ++-- crates/vim/src/visual.rs | 16 +++-- 10 files changed, 183 insertions(+), 34 deletions(-) create mode 100644 crates/vim/src/utils.rs diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index e5fdf44d3ea5238cade39d72107807854393fb9f..00e7fdba2c1e93f30979e786dda9364b192db180 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -76,7 +76,8 @@ "shift-V": [ "vim::SwitchMode", "VisualLine" - ] + ], + "p": "vim::Paste" } }, { diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 219fcba22b7c23e4a3f25f2d13557b4466d4386f..d80b03da9e655a957d7855345cec0718a77550f7 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -3,7 +3,7 @@ mod element; pub mod items; pub mod movement; mod multi_buffer; -mod selections_collection; +pub mod selections_collection; #[cfg(test)] mod test; diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index a794ac7edd9725d84c5fed4ca3aede5062f66fd7..3ef169a2e0248004f888ca9c886e88c5e706a78c 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -489,7 +489,7 @@ impl EditorElement { cx: &mut PaintContext, ) { if range.start != range.end || line_mode { - let row_range = if range.end.column() == 0 { + let row_range = if range.end.column() == 0 && !line_mode { cmp::max(range.start.row(), start_row)..cmp::min(range.end.row(), end_row) } else { cmp::max(range.start.row(), start_row)..cmp::min(range.end.row() + 1, end_row) 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/vim/src/normal.rs b/crates/vim/src/normal.rs index 2a391676fa8e0b8a5796be153386f96a054b66bd..d9b5d470e768f0b8e2810fb1bcad20c2034fdf88 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -1,6 +1,8 @@ mod change; mod delete; +use std::borrow::Cow; + use crate::{ motion::Motion, state::{Mode, Operator}, @@ -8,9 +10,9 @@ 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}; @@ -27,6 +29,8 @@ actions!( DeleteRight, ChangeToEndOfLine, DeleteToEndOfLine, + Paste, + Yank, ] ); @@ -56,6 +60,7 @@ pub fn init(cx: &mut MutableAppContext) { delete_over(vim, Motion::EndOfLine, cx); }) }); + cx.add_action(paste); change_init(cx); } @@ -187,6 +192,98 @@ fn insert_line_below(_: &mut Workspace, _: &InsertLineBelow, cx: &mut ViewContex }); } +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 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; @@ -1026,4 +1123,27 @@ 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"}); + } } diff --git a/crates/vim/src/normal/change.rs b/crates/vim/src/normal/change.rs index 0636b4b1ef7fb1fb5002a4d9d5f71fdcdcdae9ff..7f417fd31ed3167097e6eeeff52f14fc9024b690 100644 --- a/crates/vim/src/normal/change.rs +++ b/crates/vim/src/normal/change.rs @@ -1,6 +1,6 @@ -use crate::{motion::Motion, state::Mode, Vim}; -use editor::{char_kind, movement, Autoscroll, ClipboardSelection}; -use gpui::{impl_actions, ClipboardItem, MutableAppContext, ViewContext}; +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; use workspace::Workspace; @@ -22,26 +22,13 @@ pub fn change_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) { editor.transact(cx, |editor, cx| { // We are swapping to insert mode anyway. Just set the line end clipping behavior now editor.set_clip_at_line_ends(false, cx); - let mut text = String::new(); - let buffer = editor.buffer().read(cx).snapshot(cx); - let mut clipboard_selections = Vec::with_capacity(editor.selections.count()); editor.change_selections(Some(Autoscroll::Fit), cx, |s| { s.move_with(|map, selection| { motion.expand_selection(map, selection, false); - let mut len = 0; - let range = selection.start.to_point(map)..selection.end.to_point(map); - for chunk in buffer.text_for_range(range) { - text.push_str(chunk); - len += chunk.len(); - } - clipboard_selections.push(ClipboardSelection { - len, - is_entire_line: motion.linewise(), - }); }); }); + copy_selections_content(editor, motion.linewise(), cx); editor.insert(&"", cx); - cx.write_to_clipboard(ClipboardItem::new(text).with_metadata(clipboard_selections)); }); }); vim.switch_mode(Mode::Insert, cx) @@ -79,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/utils.rs b/crates/vim/src/utils.rs new file mode 100644 index 0000000000000000000000000000000000000000..1cd5f1360860ba087101ab04206cadd3424301ba --- /dev/null +++ b/crates/vim/src/utils.rs @@ -0,0 +1,26 @@ +use editor::{ClipboardSelection, Editor}; +use gpui::{ClipboardItem, MutableAppContext}; +use language::Point; + +pub fn copy_selections_content(editor: &mut Editor, linewise: bool, cx: &mut MutableAppContext) { + let selections = editor.selections.all::(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 115536e6a5ad014f57bec88b50d2549241b49b7a..00ef98987415e7acfeb3b5c92996025bc3d7c142 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -6,6 +6,7 @@ mod insert; mod motion; mod normal; mod state; +mod utils; mod visual; use collections::HashMap; @@ -140,11 +141,14 @@ impl Vim { } if state.empty_selections_only() { - editor.change_selections(None, cx, |s| { - s.move_with(|_, selection| { - selection.collapse_to(selection.head(), selection.goal) - }); - }) + // Defer so that access to global settings object doesn't panic + cx.defer(|editor, cx| { + editor.change_selections(None, cx, |s| { + s.move_with(|_, selection| { + selection.collapse_to(selection.head(), selection.goal) + }); + }) + }); } }); } diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index 0a7517bfb8078687a8b4a4db08582e967cdc15a7..480c7e07b6652baf3c9d2c033bad208f8051f34a 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -3,7 +3,7 @@ use editor::{Autoscroll, Bias}; use gpui::{actions, MutableAppContext, ViewContext}; use workspace::Workspace; -use crate::{motion::Motion, state::Mode, Vim}; +use crate::{motion::Motion, state::Mode, utils::copy_selections_content, Vim}; actions!( vim, @@ -41,7 +41,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); } }); }); @@ -63,6 +63,7 @@ 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); 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. + // 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); } }); }); + copy_selections_content(editor, false, cx); editor.insert("", cx); // Fixup cursor position after the deletion @@ -112,6 +114,7 @@ pub fn delete(_: &mut Workspace, _: &VisualDelete, cx: &mut ViewContext Date: Thu, 19 May 2022 18:20:41 -0700 Subject: [PATCH 06/26] Add visual line mode operator tests --- crates/vim/src/vim_test_context.rs | 8 +- crates/vim/src/visual.rs | 158 +++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+), 1 deletion(-) diff --git a/crates/vim/src/vim_test_context.rs b/crates/vim/src/vim_test_context.rs index f9080e554cb23638b44b5401d0c1ca762b078900..a319d6dbeade6f6a32c0382ff29b20c808ab115b 100644 --- a/crates/vim/src/vim_test_context.rs +++ b/crates/vim/src/vim_test_context.rs @@ -1,4 +1,4 @@ -use std::ops::{Deref, Range}; +use std::ops::{Deref, DerefMut, Range}; use collections::BTreeMap; use itertools::{Either, Itertools}; @@ -404,3 +404,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 480c7e07b6652baf3c9d2c033bad208f8051f34a..c3416da471eec1f4ae486353b90bef9a093afae8 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -249,6 +249,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 lazy d|quick brown + fox jumps oog"}); + cx.assert( indoc! {" The quick brown @@ -299,6 +306,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; @@ -363,4 +441,84 @@ 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 + |"}, + ); + } } From 61f0daa5c5215082d8bbba4e50a0f19711f4f231 Mon Sep 17 00:00:00 2001 From: Keith Simmons Date: Mon, 23 May 2022 09:23:25 -0700 Subject: [PATCH 07/26] Visual line mode handles soft wraps --- assets/keymaps/vim.json | 23 +++- crates/editor/src/display_map.rs | 12 ++ crates/editor/src/editor.rs | 21 ++-- crates/editor/src/element.rs | 82 +++++++----- crates/editor/src/selections_collection.rs | 14 +++ crates/gpui/src/app.rs | 50 ++++---- crates/vim/src/motion.rs | 2 + crates/vim/src/normal.rs | 8 +- crates/vim/src/normal/yank.rs | 26 ++++ crates/vim/src/state.rs | 2 + crates/vim/src/vim.rs | 19 ++- crates/vim/src/vim_test_context.rs | 8 ++ crates/vim/src/visual.rs | 139 +++++++++++++++++++-- crates/zed/src/main.rs | 4 +- 14 files changed, 314 insertions(+), 96 deletions(-) create mode 100644 crates/vim/src/normal/yank.rs diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 00e7fdba2c1e93f30979e786dda9364b192db180..f1dca985ab687aec250c3624e79ee1515c96c8e5 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -9,7 +9,7 @@ } ], "h": "vim::Left", - "backspace": "vim::Left", + "backspace": "editor::Backspace", // "vim::Left", "j": "vim::Down", "k": "vim::Up", "l": "vim::Right", @@ -57,6 +57,10 @@ "Delete" ], "shift-D": "vim::DeleteToEndOfLine", + "y": [ + "vim::PushOperator", + "Yank" + ], "i": [ "vim::SwitchMode", "Insert" @@ -77,7 +81,10 @@ "vim::SwitchMode", "VisualLine" ], - "p": "vim::Paste" + "p": "vim::Paste", + "u": "editor::Undo", + "ctrl-r": "editor::Redo", + "ctrl-o": "pane::GoBack" } }, { @@ -109,12 +116,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" } }, { @@ -122,7 +136,8 @@ "bindings": { "c": "vim::VisualLineChange", "d": "vim::VisualLineDelete", - "x": "vim::VisualLineDelete" + "x": "vim::VisualLineDelete", + "y": "vim::VisualLineYank" } }, { diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 3de44e031537a0627606faaecf1c7ceb2615c125..f76d23a187958a057f085a8b328169a99d3b508d 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -279,6 +279,18 @@ impl DisplaySnapshot { } } + pub fn expand_to_line(&self, mut range: Range) -> Range { + (range.start, _) = self.prev_line_boundary(range.start); + (range.end, _) = self.next_line_boundary(range.end); + + if range.is_empty() && range.start.row > 0 { + range.start.row -= 1; + range.start.column = self.buffer_snapshot.line_len(range.start.row); + } + + range + } + 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 d80b03da9e655a957d7855345cec0718a77550f7..f46fa831412603e91280f46e8fb5c5df8ea873d1 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1860,7 +1860,7 @@ 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); @@ -2750,7 +2750,7 @@ impl Editor { 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() { + if selection.is_empty() && !self.selections.line_mode { let old_head = selection.head(); let mut new_head = movement::left(&display_map, old_head.to_display_point(&display_map)) @@ -2783,8 +2783,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); } @@ -2807,7 +2808,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| { @@ -3347,7 +3348,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)); @@ -3378,16 +3379,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) { @@ -3453,7 +3455,7 @@ impl Editor { let line_start = selection.start - column; line_start..line_start } else { - selection.start..selection.end + selection.range() }; edits.push((range, to_insert)); @@ -3670,8 +3672,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); } diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 3ef169a2e0248004f888ca9c886e88c5e706a78c..8c0791517dc2d814f3ca557ec5b1388a9656b86c 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::{ @@ -22,7 +25,7 @@ use gpui::{ MutableAppContext, PaintContext, Quad, Scene, SizeConstraint, ViewContext, WeakViewHandle, }; use json::json; -use language::{Bias, DiagnosticSeverity}; +use language::{Bias, DiagnosticSeverity, Selection}; use settings::Settings; use smallvec::SmallVec; use std::{ @@ -32,6 +35,35 @@ use std::{ ops::Range, }; +struct SelectionLayout { + head: DisplayPoint, + range: Range, +} + +impl SelectionLayout { + fn from( + 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, @@ -345,19 +377,18 @@ impl EditorElement { scroll_top, scroll_left, bounds, - false, cx, ); } let mut cursors = SmallVec::<[Cursor; 32]>::new(); - for ((replica_id, line_mode), selections) in &layout.selections { + for (replica_id, selections) in &layout.selections { let selection_style = style.replica_selection_style(*replica_id); let corner_radius = 0.15 * layout.line_height; for selection in selections { self.paint_highlighted_range( - selection.start..selection.end, + selection.range.clone(), start_row, end_row, selection_style.selection, @@ -368,12 +399,11 @@ impl EditorElement { scroll_top, scroll_left, bounds, - *line_mode, cx, ); 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]; @@ -485,11 +515,10 @@ impl EditorElement { scroll_top: f32, scroll_left: f32, bounds: RectF, - line_mode: bool, cx: &mut PaintContext, ) { - if range.start != range.end || line_mode { - let row_range = if range.end.column() == 0 && !line_mode { + if range.start != range.end { + let row_range = if range.end.column() == 0 { cmp::max(range.start.row(), start_row)..cmp::min(range.end.row(), end_row) } else { cmp::max(range.start.row(), start_row)..cmp::min(range.end.row() + 1, end_row) @@ -506,14 +535,14 @@ impl EditorElement { .map(|row| { let line_layout = &layout.line_layouts[(row - start_row) as usize]; HighlightedRangeLine { - start_x: if row == range.start.row() && !line_mode { + start_x: if row == range.start.row() { content_origin.x() + line_layout.x_for_index(range.start.column() as usize) - scroll_left } else { content_origin.x() - scroll_left }, - end_x: if row == range.end.row() && !line_mode { + end_x: if row == range.end.row() { content_origin.x() + line_layout.x_for_index(range.end.column() as usize) - scroll_left @@ -921,7 +950,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(); @@ -945,17 +974,10 @@ impl Element for EditorElement { if Some(replica_id) == view.leader_replica_id { continue; } - remote_selections - .entry((replica_id, line_mode)) + .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::from(selection, line_mode, &display_map)); } selections.extend(remote_selections); @@ -981,15 +1003,15 @@ impl Element for EditorElement { let local_replica_id = view.leader_replica_id.unwrap_or(view.replica_id(cx)); selections.push(( - (local_replica_id, view.selections.line_mode), + 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::from( + selection, + view.selections.line_mode, + &display_map, + ) }) .collect(), )); @@ -1240,7 +1262,7 @@ pub struct LayoutState { em_width: f32, em_advance: f32, highlighted_ranges: Vec<(Range, Color)>, - selections: Vec<((ReplicaId, bool), Vec>)>, + selections: Vec<(ReplicaId, Vec)>, context_menu: Option<(DisplayPoint, ElementBox)>, code_actions_indicator: Option<(u32, ElementBox)>, } diff --git a/crates/editor/src/selections_collection.rs b/crates/editor/src/selections_collection.rs index dfed550777247fdf1ea73e6d69c82411b88cff64..db6571cee1f3c3d884912f4e269360daa5070bde 100644 --- a/crates/editor/src/selections_collection.rs +++ b/crates/editor/src/selections_collection.rs @@ -128,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, diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index eb4b9650a67dbc0568f754abb72322df659cc06b..93e5f8279dc6b3f53a990f0fdb6b25dd5903d6a6 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -755,7 +755,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 DeserializeActionCallback = fn(json: &str) -> anyhow::Result>; @@ -1263,7 +1263,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); @@ -1274,11 +1274,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 { @@ -2261,27 +2258,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); } } } @@ -5599,7 +5593,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; } }); @@ -5629,7 +5623,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; } }) @@ -6003,7 +5997,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/vim/src/motion.rs b/crates/vim/src/motion.rs index 8ab485b58c68789587f66deaf06c70251f850ae0..16533f89f1b6a4481661f841266b0c6108849dca 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -193,11 +193,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 d9b5d470e768f0b8e2810fb1bcad20c2034fdf88..0d68b29bf9409fd851dde06678f713ecc497349d 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -1,5 +1,6 @@ mod change; mod delete; +mod yank; use std::borrow::Cow; @@ -15,7 +16,7 @@ use gpui::{actions, MutableAppContext, ViewContext}; 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, @@ -69,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); }); 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 a5ae5448fb73f8bda9692858ceff215ea460e95d..c4c6d4850b751910691007f958aa9ae43a4dc314 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -26,6 +26,7 @@ pub enum Operator { Namespace(Namespace), Change, Delete, + Yank, } #[derive(Default)] @@ -80,6 +81,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/vim.rs b/crates/vim/src/vim.rs index 00ef98987415e7acfeb3b5c92996025bc3d7c142..5ee3f3d38bb11c8d53bb27e2785e42a439c39dbf 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -42,8 +42,10 @@ pub fn init(cx: &mut MutableAppContext) { }, ); - 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(); } @@ -141,14 +143,11 @@ impl Vim { } if state.empty_selections_only() { - // Defer so that access to global settings object doesn't panic - cx.defer(|editor, cx| { - editor.change_selections(None, cx, |s| { - s.move_with(|_, selection| { - selection.collapse_to(selection.head(), selection.goal) - }); - }) - }); + editor.change_selections(None, cx, |s| { + s.move_with(|_, selection| { + selection.collapse_to(selection.head(), selection.goal) + }); + }) } }); } diff --git a/crates/vim/src/vim_test_context.rs b/crates/vim/src/vim_test_context.rs index a319d6dbeade6f6a32c0382ff29b20c808ab115b..b6120848a3fc089b3d87e96bda531b4b2cbc9c78 100644 --- a/crates/vim/src/vim_test_context.rs +++ b/crates/vim/src/vim_test_context.rs @@ -337,6 +337,14 @@ 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> { diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index c3416da471eec1f4ae486353b90bef9a093afae8..17a4272117b4f1ad2f72b74888a0adbfab900942 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -9,9 +9,11 @@ actions!( vim, [ VisualDelete, - VisualChange, VisualLineDelete, - VisualLineChange + VisualChange, + VisualLineChange, + VisualYank, + VisualLineYank, ] ); @@ -20,6 +22,8 @@ pub fn init(cx: &mut MutableAppContext) { cx.add_action(change_line); cx.add_action(delete); cx.add_action(delete_line); + cx.add_action(yank); + cx.add_action(yank_line); } pub fn visual_motion(motion: Motion, cx: &mut MutableAppContext) { @@ -56,8 +60,8 @@ pub fn change(_: &mut Workspace, _: &VisualChange, cx: &mut ViewContext 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); } selection.end = map.next_line_boundary(selection.end.to_point(map)).1; @@ -161,6 +164,38 @@ pub fn delete_line(_: &mut Workspace, _: &VisualLineDelete, cx: &mut ViewContext }); } +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); + editor.change_selections(Some(Autoscroll::Fit), 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::Left); + } + }); + }); + copy_selections_content(editor, false, cx); + }); + vim.switch_mode(Mode::Normal, cx); + }); +} + +pub fn yank_line(_: &mut Workspace, _: &VisualLineYank, cx: &mut ViewContext) { + Vim::update(cx, |vim, cx| { + vim.update_active_editor(cx, |editor, cx| { + editor.set_clip_at_line_ends(false, cx); + let adjusted = editor.selections.all_adjusted(cx); + editor.change_selections(None, cx, |s| s.select(adjusted)); + copy_selections_content(editor, true, cx); + }); + vim.switch_mode(Mode::Normal, cx); + }); +} + #[cfg(test)] mod test { use indoc::indoc; @@ -521,4 +556,88 @@ mod test { |"}, ); } + + #[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 ov"})); + 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/zed/src/main.rs b/crates/zed/src/main.rs index 3e21e454f2f9f08f93fec6886938e98eb03d8647..40ef2a84ab1ea17391709db07a5ae7aee92d42a4 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -179,8 +179,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(); From 11569a869a72f786a9798c53266e28c05c79f824 Mon Sep 17 00:00:00 2001 From: Keith Simmons Date: Mon, 23 May 2022 11:04:26 -0700 Subject: [PATCH 08/26] in progress working on aborting operators on unhandled editor input --- crates/vim/src/normal.rs | 6 +++--- crates/vim/src/state.rs | 7 +++++++ crates/vim/src/vim.rs | 15 +++++++++++---- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 0d68b29bf9409fd851dde06678f713ecc497349d..48c4ad339a7f741a17127c1e2962d9c6f26b3cf6 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -1131,9 +1131,9 @@ mod test { let mut cx = VimTestContext::new(cx, true).await; cx.set_state( indoc! {" - The quick brown - fox ju|mps over - the lazy dog"}, + The quick brown + fox ju|mps over + the lazy dog"}, Mode::Normal, ); diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index c4c6d4850b751910691007f958aa9ae43a4dc314..e0ecbc33ad191b699a2d075e7b8dfea629b627ef 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -47,6 +47,13 @@ impl VimState { !matches!(self.mode, Mode::Insert) } + pub fn clip_at_line_end(&self) -> bool { + match self.mode { + Mode::Insert | Mode::Visual | Mode::VisualLine => false, + _ => true, + } + } + pub fn empty_selections_only(&self) -> bool { self.mode != Mode::Visual && self.mode != Mode::VisualLine } diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 5ee3f3d38bb11c8d53bb27e2785e42a439c39dbf..c5ab4118f7bfad23f3af708190c64e00a8ea0301 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -10,7 +10,7 @@ mod utils; mod visual; use collections::HashMap; -use editor::{CursorShape, Editor}; +use editor::{CursorShape, Editor, Input}; use gpui::{impl_actions, MutableAppContext, Subscription, ViewContext, WeakViewHandle}; use serde::Deserialize; @@ -41,6 +41,13 @@ 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() { + cx.defer(|_, cx| Vim::update(cx, |vim, cx| vim.clear_operator(cx))) + } else { + cx.propagate_action() + } + }); cx.observe_global::(|cx| { Vim::update(cx, |state, cx| { @@ -105,7 +112,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() } @@ -122,14 +129,14 @@ 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 = state.mode == Mode::VisualLine; let context_layer = state.keymap_context_layer(); From e93c49f4f02b3edaddae6a6a4cc0ac433f242357 Mon Sep 17 00:00:00 2001 From: Keith Simmons Date: Tue, 24 May 2022 13:35:57 -0700 Subject: [PATCH 09/26] Unify visual line_mode and non line_mode operators --- assets/keymaps/vim.json | 23 ++- crates/editor/src/display_map.rs | 21 ++- crates/editor/src/editor.rs | 24 +-- crates/editor/src/selections_collection.rs | 7 + crates/vim/src/editor_events.rs | 2 +- crates/vim/src/motion.rs | 3 +- crates/vim/src/normal.rs | 10 +- crates/vim/src/state.rs | 12 +- crates/vim/src/utils.rs | 3 +- crates/vim/src/vim.rs | 28 ++-- crates/vim/src/vim_test_context.rs | 21 ++- crates/vim/src/visual.rs | 167 ++++++++------------- 12 files changed, 142 insertions(+), 179 deletions(-) diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index f1dca985ab687aec250c3624e79ee1515c96c8e5..c1e5d7db8c576ca388653c0662179046000a260d 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -9,7 +9,7 @@ } ], "h": "vim::Left", - "backspace": "editor::Backspace", // "vim::Left", + "backspace": "vim::Left", "j": "vim::Down", "k": "vim::Up", "l": "vim::Right", @@ -75,11 +75,19 @@ "shift-O": "vim::InsertLineAbove", "v": [ "vim::SwitchMode", - "Visual" + { + "Visual": { + "line": false + } + } ], "shift-V": [ "vim::SwitchMode", - "VisualLine" + { + "Visual": { + "line": true + } + } ], "p": "vim::Paste", "u": "editor::Undo", @@ -131,15 +139,6 @@ "y": "vim::VisualYank" } }, - { - "context": "Editor && vim_mode == visual_line", - "bindings": { - "c": "vim::VisualLineChange", - "d": "vim::VisualLineDelete", - "x": "vim::VisualLineDelete", - "y": "vim::VisualLineYank" - } - }, { "context": "Editor && vim_mode == insert", "bindings": { diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index f76d23a187958a057f085a8b328169a99d3b508d..4378db540700dfd142551347832e10668b6f11e7 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -279,16 +279,21 @@ impl DisplaySnapshot { } } - pub fn expand_to_line(&self, mut range: Range) -> Range { - (range.start, _) = self.prev_line_boundary(range.start); - (range.end, _) = self.next_line_boundary(range.end); - - if range.is_empty() && range.start.row > 0 { - range.start.row -= 1; - range.start.column = self.buffer_snapshot.line_len(range.start.row); + 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); + } } - range + new_start..new_end } fn point_to_display_point(&self, point: Point, bias: Bias) -> DisplayPoint { diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index f46fa831412603e91280f46e8fb5c5df8ea873d1..37080789d4a813465a84fd0d528ebd22b88a89ab 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1866,7 +1866,10 @@ impl Editor { 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( @@ -1878,25 +1881,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); }) }); } diff --git a/crates/editor/src/selections_collection.rs b/crates/editor/src/selections_collection.rs index db6571cee1f3c3d884912f4e269360daa5070bde..b77e55c5cf0bb65697b05e99a56a0f78cd8aa674 100644 --- a/crates/editor/src/selections_collection.rs +++ b/crates/editor/src/selections_collection.rs @@ -22,6 +22,13 @@ pub struct PendingSelection { pub mode: SelectMode, } +#[derive(Clone)] +pub enum LineMode { + None, + WithNewline, + WithoutNewline, +} + #[derive(Clone)] pub struct SelectionsCollection { display_map: ModelHandle, diff --git a/crates/vim/src/editor_events.rs b/crates/vim/src/editor_events.rs index 1d477313846033fe2eed1d34a215a06c8e87fd68..092d369058270e8e12a1b3da03630cdc7cf8495f 100644 --- a/crates/vim/src/editor_events.rs +++ b/crates/vim/src/editor_events.rs @@ -58,7 +58,7 @@ 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.state.mode == Mode::Normal && !newest_empty { - vim.switch_mode(Mode::Visual, cx) + vim.switch_mode(Mode::Visual { line: false }, cx) } }) } diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index 16533f89f1b6a4481661f841266b0c6108849dca..221898c056220246cfd59d56b50f23639ce5953e 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -111,8 +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::VisualLine => visual_motion(motion, cx), + Mode::Visual { .. } => visual_motion(motion, cx), Mode::Insert => { // Shouldn't execute a motion in insert mode. Ignoring } diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 48c4ad339a7f741a17127c1e2962d9c6f26b3cf6..26336838816bb17e56e548f9ab38a82c5eb75a0b 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -777,14 +777,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] diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index e0ecbc33ad191b699a2d075e7b8dfea629b627ef..a08b8bd2d2103126a8d8c521c9737f9d2c1fe316 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -6,8 +6,7 @@ use serde::Deserialize; pub enum Mode { Normal, Insert, - Visual, - VisualLine, + Visual { line: bool }, } impl Default for Mode { @@ -38,7 +37,7 @@ pub struct VimState { impl VimState { pub fn cursor_shape(&self) -> CursorShape { match self.mode { - Mode::Normal | Mode::Visual | Mode::VisualLine => CursorShape::Block, + Mode::Normal | Mode::Visual { .. } => CursorShape::Block, Mode::Insert => CursorShape::Bar, } } @@ -49,13 +48,13 @@ impl VimState { pub fn clip_at_line_end(&self) -> bool { match self.mode { - Mode::Insert | Mode::Visual | Mode::VisualLine => false, + Mode::Insert | Mode::Visual { .. } => false, _ => true, } } pub fn empty_selections_only(&self) -> bool { - self.mode != Mode::Visual && self.mode != Mode::VisualLine + !matches!(self.mode, Mode::Visual { .. }) } pub fn keymap_context_layer(&self) -> Context { @@ -64,8 +63,7 @@ impl VimState { "vim_mode".to_string(), match self.mode { Mode::Normal => "normal", - Mode::Visual => "visual", - Mode::VisualLine => "visual_line", + Mode::Visual { .. } => "visual", Mode::Insert => "insert", } .to_string(), diff --git a/crates/vim/src/utils.rs b/crates/vim/src/utils.rs index 1cd5f1360860ba087101ab04206cadd3424301ba..cb6a736c6344d0c91cfdb7b5b22458ac0e9fed2e 100644 --- a/crates/vim/src/utils.rs +++ b/crates/vim/src/utils.rs @@ -1,9 +1,8 @@ use editor::{ClipboardSelection, Editor}; use gpui::{ClipboardItem, MutableAppContext}; -use language::Point; pub fn copy_selections_content(editor: &mut Editor, linewise: bool, cx: &mut MutableAppContext) { - let selections = editor.selections.all::(cx); + 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()); diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index c5ab4118f7bfad23f3af708190c64e00a8ea0301..89647b56e29f3e83c8174309e731850f9656f961 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -10,7 +10,7 @@ mod utils; mod visual; use collections::HashMap; -use editor::{CursorShape, Editor, Input}; +use editor::{Bias, CursorShape, Editor, Input}; use gpui::{impl_actions, MutableAppContext, Subscription, ViewContext, WeakViewHandle}; use serde::Deserialize; @@ -43,7 +43,8 @@ pub fn init(cx: &mut MutableAppContext) { ); cx.add_action(|_: &mut Editor, _: &Input, cx| { if Vim::read(cx).active_operator().is_some() { - cx.defer(|_, cx| Vim::update(cx, |vim, cx| vim.clear_operator(cx))) + // Defer without updating editor + MutableAppContext::defer(cx, |cx| Vim::update(cx, |vim, cx| vim.clear_operator(cx))) } else { cx.propagate_action() } @@ -138,7 +139,8 @@ impl Vim { editor.set_cursor_shape(cursor_shape, cx); editor.set_clip_at_line_ends(state.clip_at_line_end(), cx); editor.set_input_enabled(!state.vim_controlled()); - editor.selections.line_mode = state.mode == Mode::VisualLine; + 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 { @@ -149,13 +151,17 @@ impl Vim { editor.remove_keymap_context_layer::(); } - if state.empty_selections_only() { - editor.change_selections(None, cx, |s| { - s.move_with(|_, selection| { + 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) - }); - }) - } + } + }); + }) }); } } @@ -190,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 b6120848a3fc089b3d87e96bda531b4b2cbc9c78..b4a93e158c30f3c5bec7a2e2508792c9586c165b 100644 --- a/crates/vim/src/vim_test_context.rs +++ b/crates/vim/src/vim_test_context.rs @@ -12,7 +12,7 @@ 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, *}; @@ -26,6 +26,7 @@ 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(); @@ -269,9 +270,12 @@ impl<'a> VimTestContext<'a> { panic!( indoc! {" Editor has extra selection - Extra Selection Location: {} - Asserted selections: {} - Actual selections: {}"}, + Extra Selection Location: + {} + Asserted selections: + {} + Actual selections: + {}"}, location_text, asserted_selections, actual_selections, ); } @@ -279,9 +283,12 @@ impl<'a> VimTestContext<'a> { panic!( indoc! {" Editor is missing empty selection - Missing Selection Location: {} - Asserted selections: {} - Actual selections: {}"}, + Missing Selection Location: + {} + Asserted selections: + {} + Actual selections: + {}"}, location_text, asserted_selections, actual_selections, ); } diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index 17a4272117b4f1ad2f72b74888a0adbfab900942..665b468b733a1c53963318353236affe0d0fc66c 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -1,29 +1,17 @@ use collections::HashMap; -use editor::{Autoscroll, Bias}; +use editor::{display_map::ToDisplayPoint, Autoscroll, Bias}; use gpui::{actions, MutableAppContext, ViewContext}; +use language::SelectionGoal; use workspace::Workspace; use crate::{motion::Motion, state::Mode, utils::copy_selections_content, Vim}; -actions!( - vim, - [ - VisualDelete, - VisualLineDelete, - VisualChange, - VisualLineChange, - VisualYank, - VisualLineYank, - ] -); +actions!(vim, [VisualDelete, VisualChange, VisualYank,]); pub fn init(cx: &mut MutableAppContext) { cx.add_action(change); - cx.add_action(change_line); cx.add_action(delete); - cx.add_action(delete_line); cx.add_action(yank); - cx.add_action(yank_line); } pub fn visual_motion(motion: Motion, cx: &mut MutableAppContext) { @@ -32,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); @@ -57,7 +44,12 @@ pub fn change(_: &mut Workspace, _: &VisualChange, cx: &mut ViewContext) { - Vim::update(cx, |vim, cx| { - vim.update_active_editor(cx, |editor, cx| { - editor.set_clip_at_line_ends(false, cx); - - let adjusted = editor.selections.all_adjusted(cx); - editor.change_selections(None, cx, |s| s.select(adjusted)); - copy_selections_content(editor, true, cx); - editor.insert("", cx); - }); - vim.switch_mode(Mode::Insert, cx); - }); -} + if line_mode { + let range = selection.map(|p| p.to_point(map)).range(); + let expanded_range = map.expand_to_line(range); + // If we are at the last line, the anchor needs to be after the newline so that + // it is on a line of its own. Otherwise, the anchor may be after the newline + let anchor = if expanded_range.end == map.buffer_snapshot.max_point() { + map.buffer_snapshot.anchor_after(expanded_range.end) + } else { + map.buffer_snapshot.anchor_before(expanded_range.start) + }; -pub fn delete(_: &mut Workspace, _: &VisualDelete, cx: &mut ViewContext) { - Vim::update(cx, |vim, cx| { - vim.update_active_editor(cx, |editor, cx| { - editor.set_clip_at_line_ends(false, cx); - editor.change_selections(Some(Autoscroll::Fit), 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); + edits.push((expanded_range, "\n")); + new_selections.push(selection.map(|_| anchor.clone())); + } else { + let range = selection.map(|p| p.to_point(map)).range(); + let anchor = map.buffer_snapshot.anchor_after(range.end); + edits.push((range, "")); + new_selections.push(selection.map(|_| anchor.clone())); } }); }); - copy_selections_content(editor, false, cx); - editor.insert("", cx); - - // Fixup cursor position after the deletion - editor.set_clip_at_line_ends(true, cx); + copy_selections_content(editor, editor.selections.line_mode, cx); + editor.edit_with_autoindent(edits, 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); - selection.collapse_to(cursor, selection.goal) - }); + s.select_anchors(new_selections); }); }); - vim.switch_mode(Mode::Normal, cx); + vim.switch_mode(Mode::Insert, cx); }); } -pub fn delete_line(_: &mut Workspace, _: &VisualLineDelete, cx: &mut ViewContext) { +pub fn delete(_: &mut Workspace, _: &VisualDelete, cx: &mut ViewContext) { Vim::update(cx, |vim, 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| { - original_columns.insert(selection.id, selection.head().column()); - selection.start = map.prev_line_boundary(selection.start.to_point(map)).1; - - if selection.end.row() < map.max_point().row() { - *selection.end.row_mut() += 1; - *selection.end.column_mut() = 0; + 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::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); } - - selection.end = map.next_line_boundary(selection.end.to_point(map)).1; }); }); - copy_selections_content(editor, true, cx); + 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(); + let mut cursor = selection.head().to_point(map); + if let Some(column) = original_columns.get(&selection.id) { - *cursor.column_mut() = *column + cursor.column = *column } - cursor = map.clip_point(cursor, Bias::Left); + let cursor = map.clip_point(cursor.to_display_point(map), Bias::Left); selection.collapse_to(cursor, selection.goal) }); }); @@ -168,9 +133,10 @@ 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); - editor.change_selections(Some(Autoscroll::Fit), cx, |s| { + let line_mode = editor.selections.line_mode; + editor.change_selections(None, cx, |s| { s.move_with(|map, selection| { - if !selection.reversed { + if !line_mode && !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; @@ -178,19 +144,12 @@ pub fn yank(_: &mut Workspace, _: &VisualYank, cx: &mut ViewContext) } }); }); - copy_selections_content(editor, false, cx); - }); - vim.switch_mode(Mode::Normal, cx); - }); -} - -pub fn yank_line(_: &mut Workspace, _: &VisualLineYank, cx: &mut ViewContext) { - Vim::update(cx, |vim, cx| { - vim.update_active_editor(cx, |editor, cx| { - editor.set_clip_at_line_ends(false, cx); - let adjusted = editor.selections.all_adjusted(cx); - editor.change_selections(None, cx, |s| s.select(adjusted)); - copy_selections_content(editor, true, cx); + 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); }); @@ -205,7 +164,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 @@ -236,7 +197,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 @@ -576,7 +539,7 @@ mod test { ); cx.assert_clipboard_content(Some(indoc! {" quick brown - fox jumps ov"})); + fox jumps o"})); cx.assert( indoc! {" The quick brown @@ -608,7 +571,7 @@ mod test { fox jumps over the lazy dog"}, indoc! {" - The |quick brown + |The quick brown fox jumps over the lazy dog"}, ); @@ -620,8 +583,8 @@ mod test { the |lazy dog"}, indoc! {" The quick brown - fox jumps over - the |lazy dog"}, + |fox jumps over + the lazy dog"}, ); cx.assert_clipboard_content(Some(indoc! {" fox jumps over @@ -632,8 +595,8 @@ mod test { fox jumps |over the lazy dog"}, indoc! {" - The quick brown - fox jumps |over + The |quick brown + fox jumps over the lazy dog"}, ); cx.assert_clipboard_content(Some(indoc! {" From 98f9575653c4fad6ece47e85813d966e9bda199f Mon Sep 17 00:00:00 2001 From: Keith Simmons Date: Wed, 25 May 2022 10:22:49 -0700 Subject: [PATCH 10/26] WIP --- crates/editor/src/editor.rs | 304 ++++++++++++---------------- crates/editor/src/test.rs | 312 ++++++++++++++++++++++++++++- crates/vim/src/vim_test_context.rs | 263 +++--------------------- 3 files changed, 457 insertions(+), 422 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 37080789d4a813465a84fd0d528ebd22b88a89ab..bb181a6ea1d2c374261a033288345f2da8ff722c 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -5,8 +5,8 @@ pub mod movement; mod multi_buffer; pub mod selections_collection; -#[cfg(test)] -mod test; +#[cfg(any(test, feature = "test-support"))] +pub mod test; use aho_corasick::AhoCorasick; use anyhow::Result; @@ -6017,7 +6017,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::{ @@ -7289,117 +7291,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)); - - 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, - ); + async fn test_indent_outdent(cx: &mut gpui::TestAppContext) { + let mut cx = EditorTestContext::new(cx).await; - // 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] @@ -7508,73 +7455,74 @@ 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(indoc! {" + | + the lazy dog|"}); } #[gpui::test] @@ -9795,10 +9743,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/test.rs b/crates/editor/src/test.rs index cb064be5459c3abd7af6ae9c9ed56aca06758acd..a8bcc94ee29a7d2829f8e5734a3e4d6be679650b 100644 --- a/crates/editor/src/test.rs +++ b/crates/editor/src/test.rs @@ -1,9 +1,20 @@ -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 itertools::{Either, Itertools}; +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 +67,298 @@ 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 (text_with_ranges, empty_selections) = marked_text(&text); + let (unmarked_text, mut selection_ranges) = + marked_text_ranges_by(&text_with_ranges, vec![('[', '}'), ('{', ']')]); + editor.set_text(unmarked_text, cx); + + let mut selections: Vec> = empty_selections + .into_iter() + .map(|offset| offset..offset) + .collect(); + selections.extend(selection_ranges.remove(&('{', ']')).unwrap_or_default()); + selections.extend(selection_ranges.remove(&('[', '}')).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 (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, + ) + }); + 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 + } + + 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/vim/src/vim_test_context.rs b/crates/vim/src/vim_test_context.rs index b4a93e158c30f3c5bec7a2e2508792c9586c165b..52d4778b383c1bc97c37df2f7b9162f7d3d9fdb8 100644 --- a/crates/vim/src/vim_test_context.rs +++ b/crates/vim/src/vim_test_context.rs @@ -1,25 +1,14 @@ -use std::ops::{Deref, DerefMut, 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::{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> { @@ -70,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, + }, } } @@ -101,225 +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( @@ -331,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); } @@ -355,10 +134,16 @@ impl<'a> VimTestContext<'a> { } 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 } } From e104cb94e77592bca13ce461108e6c566c67bcae Mon Sep 17 00:00:00 2001 From: Keith Simmons Date: Wed, 25 May 2022 14:13:18 -0700 Subject: [PATCH 11/26] fix bug in marked_range utils --- crates/editor/src/editor.rs | 220 +++++++++++----------------- crates/editor/src/test.rs | 130 ++++++++-------- crates/util/src/test/marked_text.rs | 96 ++++++++---- 3 files changed, 218 insertions(+), 228 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index bb181a6ea1d2c374261a033288345f2da8ff722c..e7af70f303540d0d2a282f9f52fc685039002925 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -2735,28 +2735,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() && !self.selections.line_mode { - 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); + } } } @@ -7492,8 +7495,7 @@ mod tests { |The qu[ick b}rown"}); cx.update_editor(|e, cx| e.backspace(&Backspace, cx)); cx.assert_editor_state(indoc! {" - | - fox jumps over + |fox jumps over the lazy dog|"}); } @@ -7516,13 +7518,11 @@ mod tests { cx.update_editor(|e, _| e.selections.line_mode = true); cx.set_state(indoc! {" The |quick |brown - fox {jum]ps over| + 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(indoc! {" - | - the lazy dog|"}); + cx.assert_editor_state("|the lazy dog|"); } #[gpui::test] @@ -7830,131 +7830,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] @@ -8693,8 +8641,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); } diff --git a/crates/editor/src/test.rs b/crates/editor/src/test.rs index a8bcc94ee29a7d2829f8e5734a3e4d6be679650b..4c9ceed9aedeb334bb716cd505edcd015cb29337 100644 --- a/crates/editor/src/test.rs +++ b/crates/editor/src/test.rs @@ -4,7 +4,6 @@ use indoc::indoc; use collections::BTreeMap; use gpui::{keymap::Keystroke, ModelHandle, ViewContext, ViewHandle}; -use itertools::{Either, Itertools}; use language::Selection; use settings::Settings; use util::{ @@ -138,17 +137,26 @@ impl<'a> EditorTestContext<'a> { // `{` 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 (text_with_ranges, empty_selections) = marked_text(&text); - let (unmarked_text, mut selection_ranges) = - marked_text_ranges_by(&text_with_ranges, vec![('[', '}'), ('{', ']')]); + 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> = empty_selections - .into_iter() - .map(|offset| offset..offset) - .collect(); - selections.extend(selection_ranges.remove(&('{', ']')).unwrap_or_default()); - selections.extend(selection_ranges.remove(&('[', '}')).unwrap_or_default()); + 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)); }) @@ -159,17 +167,23 @@ impl<'a> EditorTestContext<'a> { // `[` 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 (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_reverse_selections = selection_ranges.remove(&('{', ']')).unwrap_or_default(); - let expected_forward_selections = selection_ranges.remove(&('[', '}')).unwrap_or_default(); + 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, @@ -180,65 +194,53 @@ impl<'a> EditorTestContext<'a> { } 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) - } - }); + 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( - expected_empty_selections, - expected_reverse_selections, - expected_forward_selections, + empty_selections, + reverse_selections, + forward_selections, None, ) } fn assert_selections( &mut self, - expected_empty_selections: Vec, + 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) - } - }); + 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) }); @@ -258,7 +260,7 @@ impl<'a> EditorTestContext<'a> { .map_err(|err| { err.map(|missing| { let mut error_text = unmarked_text.clone(); - error_text.insert(missing, '|'); + error_text.insert(missing.start, '|'); error_text }) }) @@ -316,14 +318,14 @@ impl<'a> EditorTestContext<'a> { fn insert_markers( &mut self, - empty_selections: &Vec, + 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 empty_selections { + selection_marks.insert(&range.start, '|'); } for range in reverse_selections { selection_marks.insert(&range.start, '{'); 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) From d11bc2a4b75433b2c125d42b172c7c85ae0794ac Mon Sep 17 00:00:00 2001 From: Keith Simmons Date: Thu, 26 May 2022 11:28:05 -0700 Subject: [PATCH 12/26] Fixup paste locations --- crates/editor/src/editor.rs | 32 ++++++++++-------- crates/editor/src/selections_collection.rs | 7 ---- crates/vim/src/normal.rs | 39 ++++++++++++++++++++++ crates/vim/src/visual.rs | 30 +++++++++-------- 4 files changed, 75 insertions(+), 33 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index e7af70f303540d0d2a282f9f52fc685039002925..b7ffe9ebbfb759a2f3e844741665a087e263fd49 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1542,12 +1542,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); @@ -3283,8 +3281,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; } @@ -3422,6 +3421,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; @@ -3439,7 +3439,7 @@ 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 @@ -3494,8 +3494,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 @@ -3513,8 +3514,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 @@ -3547,8 +3549,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); @@ -3578,8 +3581,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); @@ -3680,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_subword_start(map, selection.head()); selection.set_head(cursor, SelectionGoal::None); } @@ -3734,8 +3739,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); } diff --git a/crates/editor/src/selections_collection.rs b/crates/editor/src/selections_collection.rs index b77e55c5cf0bb65697b05e99a56a0f78cd8aa674..db6571cee1f3c3d884912f4e269360daa5070bde 100644 --- a/crates/editor/src/selections_collection.rs +++ b/crates/editor/src/selections_collection.rs @@ -22,13 +22,6 @@ pub struct PendingSelection { pub mode: SelectMode, } -#[derive(Clone)] -pub enum LineMode { - None, - WithNewline, - WithoutNewline, -} - #[derive(Clone)] pub struct SelectionsCollection { display_map: ModelHandle, diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 26336838816bb17e56e548f9ab38a82c5eb75a0b..55c9779581d19cd2fb4fd38dd4097eeb54aa97ed 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -194,6 +194,7 @@ 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| { @@ -256,6 +257,23 @@ fn paste(_: &mut Workspace, _: &Paste, cx: &mut ViewContext) { 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 @@ -1141,5 +1159,26 @@ mod test { 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/visual.rs b/crates/vim/src/visual.rs index 665b468b733a1c53963318353236affe0d0fc66c..3020db5e4c9ef6a621c8f646f9e50ec0657a827f 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -6,7 +6,7 @@ use workspace::Workspace; use crate::{motion::Motion, state::Mode, utils::copy_selections_content, Vim}; -actions!(vim, [VisualDelete, VisualChange, VisualYank,]); +actions!(vim, [VisualDelete, VisualChange, VisualYank]); pub fn init(cx: &mut MutableAppContext) { cx.add_action(change); @@ -55,7 +55,7 @@ pub fn change(_: &mut Workspace, _: &VisualChange, cx: &mut ViewContext) vim.update_active_editor(cx, |editor, cx| { editor.set_clip_at_line_ends(false, cx); let line_mode = editor.selections.line_mode; - editor.change_selections(None, cx, |s| { - s.move_with(|map, selection| { - if !line_mode && !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); - } + 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| { @@ -251,8 +255,8 @@ mod test { cx.simulate_keystrokes(["j", "p"]); cx.assert_editor_state(indoc! {" The ver - the lazy d|quick brown - fox jumps oog"}); + the l|quick brown + fox jumps oazy dog"}); cx.assert( indoc! {" From c53412efcb57eca03bc66eafef70b1e838ea1ddf Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Thu, 26 May 2022 12:26:19 -0700 Subject: [PATCH 13/26] Bump protocol version --- crates/rpc/src/rpc.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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; From 42cd2ae1426b26f6821b9a47a18151d0159f8b68 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Thu, 26 May 2022 12:47:16 -0700 Subject: [PATCH 14/26] Avoid switching to visual mode when following in vim mode Co-authored-by: Keith Simmons --- crates/editor/src/editor.rs | 4 ++++ crates/vim/src/editor_events.rs | 10 ++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index b7ffe9ebbfb759a2f3e844741665a087e263fd49..b07ca1df2e588dc4b951c78f810c720baa66abdb 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1025,6 +1025,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 } diff --git a/crates/vim/src/editor_events.rs b/crates/vim/src/editor_events.rs index 092d369058270e8e12a1b3da03630cdc7cf8495f..8837f264d35eb40233685dfbf30ac5189359b618 100644 --- a/crates/vim/src/editor_events.rs +++ b/crates/vim/src/editor_events.rs @@ -21,9 +21,11 @@ fn editor_focused(EditorFocused(editor): &EditorFocused, cx: &mut MutableAppCont Vim::update(cx, |vim, cx| { vim.active_editor = Some(editor.downgrade()); vim.selection_subscription = Some(cx.subscribe(editor, |editor, event, cx| { - 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).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); + } } })); @@ -57,7 +59,7 @@ 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.state.mode == Mode::Normal && !newest_empty { + if vim.enabled && vim.state.mode == Mode::Normal && !newest_empty { vim.switch_mode(Mode::Visual { line: false }, cx) } }) From 8e7c6871dbe0987a0b0a87a0b0b58ec1a234f8c3 Mon Sep 17 00:00:00 2001 From: Keith Simmons Date: Thu, 26 May 2022 17:00:03 -0700 Subject: [PATCH 15/26] Track selection changes in mutable selections collection --- crates/editor/src/editor.rs | 11 ++-- crates/editor/src/element.rs | 10 +-- crates/editor/src/selections_collection.rs | 75 +++++++++++----------- 3 files changed, 47 insertions(+), 49 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index b07ca1df2e588dc4b951c78f810c720baa66abdb..f4f3483641f06f290952736568268acbca30ec68 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1401,12 +1401,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 } @@ -4691,6 +4693,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 = diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 8c0791517dc2d814f3ca557ec5b1388a9656b86c..d5a59c2ecc304b7aaf33d8171099e7a3b5c45738 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -41,7 +41,7 @@ struct SelectionLayout { } impl SelectionLayout { - fn from( + fn new( selection: Selection, line_mode: bool, map: &DisplaySnapshot, @@ -977,7 +977,7 @@ impl Element for EditorElement { remote_selections .entry(replica_id) .or_insert(Vec::new()) - .push(SelectionLayout::from(selection, line_mode, &display_map)); + .push(SelectionLayout::new(selection, line_mode, &display_map)); } selections.extend(remote_selections); @@ -1007,11 +1007,7 @@ impl Element for EditorElement { local_selections .into_iter() .map(|selection| { - SelectionLayout::from( - selection, - view.selections.line_mode, - &display_map, - ) + SelectionLayout::new(selection, view.selections.line_mode, &display_map) }) .collect(), )); diff --git a/crates/editor/src/selections_collection.rs b/crates/editor/src/selections_collection.rs index db6571cee1f3c3d884912f4e269360daa5070bde..7041062133666a645c3ff18212af2945c918937e 100644 --- a/crates/editor/src/selections_collection.rs +++ b/crates/editor/src/selections_collection.rs @@ -289,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, }; @@ -300,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, } @@ -323,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) { @@ -345,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 { @@ -357,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; } @@ -371,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, @@ -453,6 +455,7 @@ impl<'a> MutableSelectionsCollection<'a> { })); self.collection.pending = None; + self.selections_changed = true; } pub fn select_anchors(&mut self, selections: Vec>) { @@ -551,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( @@ -686,6 +698,7 @@ impl<'a> MutableSelectionsCollection<'a> { pending.selection.end = end; } self.collection.pending = pending; + self.selections_changed = true; selections_with_lost_position } @@ -730,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 -} From 04bd57b2c7870bb198bd3a3b51769ce4d276e382 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 27 May 2022 10:45:55 -0700 Subject: [PATCH 16/26] Add an API for setting a window's title This controls how the window appears in the Window menu. --- crates/gpui/src/app.rs | 22 ++++++++++++++++++++-- crates/gpui/src/platform.rs | 1 + crates/gpui/src/platform/mac/platform.rs | 5 +++++ crates/gpui/src/platform/mac/window.rs | 9 ++++++++- crates/gpui/src/platform/test.rs | 10 ++++++++++ 5 files changed, 44 insertions(+), 3 deletions(-) diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index eb4b9650a67dbc0568f754abb72322df659cc06b..2604848e3b43376ef77a81ff70cd954e416ab3d8 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -542,12 +542,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() @@ -3265,6 +3276,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, 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 { From a1a4c7084582236cf83e9bdf5cffe3142fb55781 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 27 May 2022 10:48:47 -0700 Subject: [PATCH 17/26] Emit an event when adding a worktree to a project --- crates/collab/src/rpc.rs | 21 +++++++++++---------- crates/project/src/project.rs | 2 ++ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 7ea925fce7d8dea824aeb24b6be936f0099655b0..6371e5178450c6abe3c1aa8684e0b77954c36fcb 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -2234,7 +2234,6 @@ mod tests { .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| { @@ -2242,15 +2241,6 @@ mod tests { }) .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| { @@ -2262,6 +2252,17 @@ mod tests { .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete()) .await; + 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(); diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index abcd667293478ab1d13673d657e4e476852c5c38..964ee1c97e4b63454cf325fccf4189500acacf5c 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, @@ -3637,6 +3638,7 @@ impl Project { self.worktrees .push(WorktreeHandle::Weak(worktree.downgrade())); } + cx.emit(Event::WorktreeAdded); cx.notify(); } From e6be151a642bbc790d851294839b2b8b0784e35a Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 27 May 2022 10:49:10 -0700 Subject: [PATCH 18/26] Emit the WorktreeRemoved event when removing a worktree from a project --- crates/project/src/project.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 964ee1c97e4b63454cf325fccf4189500acacf5c..808561f1105bc425f4195c27fce097c0646b018e 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -3603,11 +3603,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(); } From a88b4eb3c507f740f2e35a0b29353953c43fc962 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 27 May 2022 10:51:12 -0700 Subject: [PATCH 19/26] Populate the window title whenever worktrees or active path change * Refactor the way the project's active entry is assigned. Assign it together with the window title, as opposed to on every notification from a pane. * Emit the ActiveItem event from panes consistently, even when adding the first item to an empty pane. --- crates/workspace/src/pane.rs | 31 +++-- crates/workspace/src/workspace.rs | 223 ++++++++++++++++++++++++------ crates/zed/src/menus.rs | 4 + 3 files changed, 210 insertions(+), 48 deletions(-) diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 8b97ef1a80476d5c33b4ff8dc18aaaa1ecf9bd88..f6c516a4452a01bd3d187be73c5269fe00669edc 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 e9f0efa31115dac5d98eb13826526f4dc96994ec..fc8d3ba16edbbab239737583f38cca70e8edd59b 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -38,6 +38,7 @@ use status_bar::StatusBar; pub use status_bar::StatusItemView; use std::{ any::{Any, TypeId}, + borrow::Cow, cell::RefCell, fmt, future::Future, @@ -532,7 +533,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(); @@ -744,6 +748,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() { @@ -755,14 +762,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); @@ -825,6 +826,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 } @@ -1238,14 +1244,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()); @@ -1385,6 +1385,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(); } @@ -1419,6 +1420,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 { @@ -1451,6 +1460,8 @@ impl Workspace { self.unfollow(&pane, cx); self.last_leaders_by_pane.remove(&pane.downgrade()); cx.notify(); + } else { + self.active_item_path_changed(cx); } } @@ -1638,15 +1649,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( @@ -1682,6 +1685,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 @@ -2417,6 +2464,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(); @@ -2456,18 +2607,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] @@ -2667,6 +2806,7 @@ mod tests { is_dirty: bool, has_conflict: bool, project_entry_ids: Vec, + project_path: Option, is_singleton: bool, } @@ -2679,6 +2819,7 @@ mod tests { is_dirty: false, has_conflict: false, project_entry_ids: Vec::new(), + project_path: None, is_singleton: true, } } @@ -2704,7 +2845,7 @@ mod tests { } fn project_path(&self, _: &AppContext) -> Option { - None + self.project_path.clone() } fn project_entry_ids(&self, _: &AppContext) -> SmallVec<[ProjectEntryId; 3]> { @@ -2763,5 +2904,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/menus.rs b/crates/zed/src/menus.rs index cfe4ca082688b37ac73d720cfd47d5e8a32c4cd2..e90b716d02af56883167de353375ec29c57ffcdb 100644 --- a/crates/zed/src/menus.rs +++ b/crates/zed/src/menus.rs @@ -229,6 +229,10 @@ pub fn menus() -> Vec> { }, ], }, + Menu { + name: "Window", + items: vec![MenuItem::Separator], + }, Menu { name: "Help", items: vec![MenuItem::Action { From 23cd948b5fcf767ceab49c78d615da803ba7a7fa Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 27 May 2022 10:53:14 -0700 Subject: [PATCH 20/26] Adjust test to flush effects between splitting pane and following Panes now emit an event when adding the first item, so we need to flush effects between splitting and following in order to avoid accidentally cancelling the follow. --- crates/collab/src/rpc.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 6371e5178450c6abe3c1aa8684e0b77954c36fcb..51c2a42225567b3937b0a75abb856f28a5a21e37 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -5856,6 +5856,9 @@ mod tests { .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) @@ -5867,6 +5870,9 @@ mod tests { .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) From 4a5317b6e4e535898250eb0096d5061492b4c75d Mon Sep 17 00:00:00 2001 From: Keith Simmons Date: Fri, 27 May 2022 11:32:15 -0700 Subject: [PATCH 21/26] Remove unused context_menu file in rust crate --- crates/editor/src/context_menu.rs | 272 ------------------------------ 1 file changed, 272 deletions(-) delete mode 100644 crates/editor/src/context_menu.rs 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) - } -} From e1a05d451fc22977205663fcdd40c4a51b40c579 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 27 May 2022 13:02:52 -0700 Subject: [PATCH 22/26] Add "Reset Zoom" action and application menu item --- assets/keymaps/default.json | 1 + crates/settings/src/settings.rs | 4 ++++ crates/zed/src/menus.rs | 4 ++++ crates/zed/src/zed.rs | 7 +++++++ 4 files changed, 16 insertions(+) diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index 1049e216f3d6452641da021f23e6b7efc9084e43..831c076b5c5c36c89dc0e03a37b77b2171e39590 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/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/zed/src/menus.rs b/crates/zed/src/menus.rs index e90b716d02af56883167de353375ec29c57ffcdb..b02e7204fbb2f6f7aab3bae9d676cfffd4a717dd 100644 --- a/crates/zed/src/menus.rs +++ b/crates/zed/src/menus.rs @@ -164,6 +164,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", diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index c2f6c60ea673a48b90e1d38c65648ca489615d86..aeba7551ebc40dfeb9ce8ab46fecc5952d7d1c33 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -43,6 +43,7 @@ actions!( OpenKeymap, IncreaseBufferFontSize, DecreaseBufferFontSize, + ResetBufferFontSize, InstallCommandLineInterface, ] ); @@ -72,6 +73,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); From df4f3051bced1a4665b4e4023c6bbc6b03fedb61 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 27 May 2022 13:06:35 -0700 Subject: [PATCH 23/26] Add app menu items for opening settings and keymap --- crates/zed/src/menus.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/zed/src/menus.rs b/crates/zed/src/menus.rs index b02e7204fbb2f6f7aab3bae9d676cfffd4a717dd..faedbe9d45e6671001e4e7f0352a1ac5e31a6602 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), From 1c932ae4cedc74004429af80c98da3b78679981f Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 27 May 2022 13:16:39 -0700 Subject: [PATCH 24/26] Add help menu items to visit zed.dev and the zed twitter page --- crates/zed/src/menus.rs | 23 +++++++++++++++++++---- crates/zed/src/zed.rs | 10 ++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/crates/zed/src/menus.rs b/crates/zed/src/menus.rs index faedbe9d45e6671001e4e7f0352a1ac5e31a6602..cc5f128bc014462e77400f7a3ade0c0f1e0503ad 100644 --- a/crates/zed/src/menus.rs +++ b/crates/zed/src/menus.rs @@ -247,10 +247,25 @@ pub fn menus() -> Vec> { }, 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 aeba7551ebc40dfeb9ce8ab46fecc5952d7d1c33..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, [ @@ -61,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); From 8393ae88b75d26e74ccac930a7b2671acb9235e4 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 27 May 2022 17:10:45 -0700 Subject: [PATCH 25/26] Clean up integration tests * Use 'build_local_project' helper to reduce boilerplate * Peform the setup steps in a consistent order --- crates/collab/src/rpc.rs | 1020 ++++++++++---------------------------- 1 file changed, 254 insertions(+), 766 deletions(-) diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 51c2a42225567b3937b0a75abb856f28a5a21e37..18b9768f1156ffd93a5ddbfe84ecf53558e3341f 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -86,7 +86,6 @@ pub struct Server { notifications: Option>, } - pub trait Executor: Send + Clone { type Sleep: Send + Future; fn spawn_detached>(&self, future: F); @@ -120,11 +119,10 @@ pub fn serialize_deref(value: &T, serializer: S) -> Result, - U: Serialize + U: Serialize, { Serialize::serialize(value.deref(), serializer) } - impl Server { pub fn new( @@ -313,7 +311,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), @@ -436,19 +434,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(()) } @@ -1488,11 +1496,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, } } } @@ -1753,20 +1761,16 @@ mod tests { 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 (window_b, _) = cx_b.add_window(|_| EmptyView); 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_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 fs = FakeFs::new(cx_a.background()); fs.insert_tree( "/a", json!({ @@ -1780,28 +1784,9 @@ mod tests { }), ) .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; + + 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; @@ -1883,7 +1868,7 @@ mod tests { project_id, client_b2.client.clone(), client_b2.user_store.clone(), - lang_registry.clone(), + client_b2.language_registry.clone(), FakeFs::new(cx_b2.background()), &mut cx_b2.to_async(), ) @@ -1920,19 +1905,15 @@ mod tests { 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_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 fs = FakeFs::new(cx_a.background()); fs.insert_tree( "/a", json!({ @@ -1941,29 +1922,13 @@ mod tests { }), ) .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_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 @@ -1986,7 +1951,10 @@ mod tests { .unwrap(); // When client A (the host) leaves, the project gets unshared and guests are notified. - cx_a.update(|_| drop(project_a)); + 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()); @@ -2001,13 +1969,9 @@ mod tests { 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_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 @@ -2018,7 +1982,7 @@ mod tests { ]) .await; - // Share a project as client A + let fs = FakeFs::new(cx_a.background()); fs.insert_tree( "/a", json!({ @@ -2027,53 +1991,31 @@ mod tests { }), ) .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_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| { - 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 - } + 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(); @@ -2111,53 +2053,31 @@ mod tests { 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_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 + let fs = FakeFs::new(cx_a.background()); 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; + + 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 lang_registry = lang_registry.clone(); + let language_registry = client_b.language_registry.clone(); async move { Project::remote( project_id, client, user_store, - lang_registry.clone(), + language_registry, FakeFs::new(cx.background()), &mut cx, ) @@ -2177,13 +2097,12 @@ mod tests { 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(), + client_b.language_registry.clone(), FakeFs::new(cx.background()), &mut cx, ) @@ -2193,7 +2112,10 @@ mod tests { // Close the project on the host deterministic.run_until_parked(); - cx_a.update(|_| drop(project_a)); + cx_a.update(|_| { + drop(project_a); + client_a.project.take(); + }); deterministic.run_until_parked(); assert!(matches!( project_b.await.unwrap_err(), @@ -2207,32 +2129,19 @@ mod tests { 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_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 + let fs = FakeFs::new(cx_a.background()); 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, _) = 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 @@ -2242,16 +2151,6 @@ mod tests { .await .unwrap(); - 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_a_events = Rc::new(RefCell::new(Vec::new())); project_a.update(cx_a, { let project_a_events = project_a_events.clone(); @@ -2267,13 +2166,13 @@ mod tests { 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(); + let language_registry = client_b.language_registry.clone(); async move { Project::remote( project_id, client, user_store, - lang_registry.clone(), + language_registry.clone(), FakeFs::new(cx.background()), &mut cx, ) @@ -2307,13 +2206,9 @@ mod tests { 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_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 @@ -2324,7 +2219,7 @@ mod tests { ]) .await; - // Share a worktree as client A. + let fs = FakeFs::new(cx_a.background()); fs.insert_tree( "/a", json!({ @@ -2333,25 +2228,9 @@ mod tests { }), ) .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 (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; @@ -2473,9 +2352,6 @@ mod tests { 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; @@ -2483,7 +2359,7 @@ mod tests { .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)]) .await; - // Share a project as client A + let fs = FakeFs::new(cx_a.background()); fs.insert_tree( "/dir", json!({ @@ -2635,18 +2511,14 @@ mod tests { #[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_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 fs = FakeFs::new(cx_a.background()); fs.insert_tree( "/dir", json!({ @@ -2655,27 +2527,7 @@ mod tests { ) .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_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 @@ -2708,18 +2560,14 @@ mod tests { #[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_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 fs = FakeFs::new(cx_a.background()); fs.insert_tree( "/dir", json!({ @@ -2728,29 +2576,8 @@ mod tests { ) .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_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; - 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 @@ -2781,18 +2608,14 @@ mod tests { 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_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 fs = FakeFs::new(cx_a.background()); fs.insert_tree( "/dir", json!({ @@ -2800,27 +2623,8 @@ mod tests { }), ) .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_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 @@ -2851,18 +2655,14 @@ mod tests { 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_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 fs = FakeFs::new(cx_a.background()); fs.insert_tree( "/dir", json!({ @@ -2870,27 +2670,8 @@ mod tests { }), ) .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_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. @@ -2917,18 +2698,14 @@ mod tests { #[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_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 fs = FakeFs::new(cx_a.background()); fs.insert_tree( "/a", json!({ @@ -2937,26 +2714,8 @@ mod tests { }), ) .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_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. @@ -2995,8 +2754,17 @@ mod tests { cx_c: &mut TestAppContext, ) { deterministic.forbid_parking(); - let lang_registry = Arc::new(LanguageRegistry::test()); - let fs = FakeFs::new(cx_a.background()); + 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( @@ -3008,22 +2776,12 @@ mod tests { Some(tree_sitter_rust::language()), ); let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()); - lang_registry.add(Arc::new(language)); + client_a.language_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 + let fs = FakeFs::new(cx_a.background()); fs.insert_tree( "/a", json!({ @@ -3032,26 +2790,8 @@ mod tests { }), ) .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_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; - let worktree_id = worktree_a.read_with(cx_a, |tree, _| tree.id()); // Cause the language server to start. let _buffer = cx_a @@ -3260,8 +3000,12 @@ mod tests { cx_b: &mut TestAppContext, ) { cx_a.foreground().forbid_parking(); - let lang_registry = Arc::new(LanguageRegistry::test()); - let fs = FakeFs::new(cx_a.background()); + 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( @@ -3282,17 +3026,9 @@ mod tests { }, ..Default::default() }); - lang_registry.add(Arc::new(language)); + client_a.language_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 fs = FakeFs::new(cx_a.background()); fs.insert_tree( "/a", json!({ @@ -3301,27 +3037,8 @@ mod tests { }), ) .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_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. @@ -3453,18 +3170,14 @@ mod tests { #[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_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 fs = FakeFs::new(cx_a.background()); fs.insert_tree( "/a", json!({ @@ -3472,31 +3185,13 @@ mod tests { }), ) .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 (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(); - // 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 @@ -3561,8 +3256,12 @@ mod tests { #[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()); + 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( @@ -3574,17 +3273,9 @@ mod tests { 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; + client_a.language_registry.add(Arc::new(language)); - // Share a project as client A + let fs = FakeFs::new(cx_a.background()); fs.insert_tree( "/a", json!({ @@ -3592,27 +3283,8 @@ mod tests { }), ) .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_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 @@ -3650,7 +3322,13 @@ mod tests { #[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 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", @@ -3677,38 +3355,9 @@ mod tests { 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; + client_a.language_registry.add(Arc::new(language)); - // 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_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. @@ -3785,7 +3434,13 @@ mod tests { #[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 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", @@ -3803,48 +3458,19 @@ mod tests { ) .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. + // 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. @@ -3917,7 +3543,13 @@ mod tests { #[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 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", @@ -3937,34 +3569,7 @@ mod tests { ) .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 (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) @@ -3975,8 +3580,9 @@ mod tests { .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; + + // Perform a search as the guest. let results = project_b .update(cx_b, |project, cx| { project.search(SearchQuery::text("world", false, false), cx) @@ -4013,7 +3619,13 @@ mod tests { #[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 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", @@ -4033,38 +3645,9 @@ mod tests { 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()); + client_a.language_registry.add(Arc::new(language)); - // Join the worktree as client B. + 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. @@ -4141,7 +3724,25 @@ mod tests { #[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 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", @@ -4159,48 +3760,9 @@ mod tests { ) .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()) + let (project_a, worktree_id) = client_a + .build_local_project(fs, "/code/crate-1", cx_a) .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. @@ -4269,16 +3831,12 @@ mod tests { 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; + 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( @@ -4290,39 +3848,19 @@ mod tests { 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, - ) - }); + client_a.language_registry.add(Arc::new(language)); - 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()); + 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; - // Join the project as client B. + 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 @@ -4365,9 +3903,13 @@ mod tests { 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)); + 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( @@ -4379,17 +3921,9 @@ mod tests { 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; + client_a.language_registry.add(Arc::new(language)); - // Share a project as client A + let fs = FakeFs::new(cx_a.background()); fs.insert_tree( "/a", json!({ @@ -4398,25 +3932,7 @@ mod tests { }), ) .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 (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; @@ -4591,9 +4107,13 @@ mod tests { #[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)); + 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( @@ -4614,17 +4134,9 @@ mod tests { }, ..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; + client_a.language_registry.add(Arc::new(language)); - // Share a project as client A + let fs = FakeFs::new(cx_a.background()); fs.insert_tree( "/dir", json!({ @@ -4633,28 +4145,10 @@ mod tests { }), ) .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_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| { @@ -4795,8 +4289,6 @@ mod tests { #[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; @@ -4935,7 +4427,6 @@ mod tests { #[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; @@ -4995,11 +4486,10 @@ mod tests { #[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. @@ -5213,8 +4703,6 @@ mod tests { 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; @@ -5852,11 +5340,10 @@ mod tests { .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| { + 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(); @@ -5866,11 +5353,10 @@ mod tests { }) .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| { + 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(); @@ -5942,7 +5428,6 @@ mod tests { #[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; @@ -5955,6 +5440,7 @@ mod tests { cx_b.update(editor::init); // Client A shares a project. + let fs = FakeFs::new(cx_a.background()); fs.insert_tree( "/a", json!({ @@ -5965,8 +5451,6 @@ mod tests { ) .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. @@ -6639,7 +6123,11 @@ mod tests { 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() + self.app_state + .db + .create_user(name, None, false) + .await + .unwrap() }; let client_name = name.to_string(); let mut client = Client::new(http.clone()); From bc6f8da0290d3f6376915cbc8e5c91f90e960d54 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 27 May 2022 17:20:05 -0700 Subject: [PATCH 26/26] Move integration tests into their own file --- crates/collab/src/integration_tests.rs | 5216 +++++++++++++++++++++++ crates/collab/src/main.rs | 3 + crates/collab/src/rpc.rs | 5279 +----------------------- 3 files changed, 5222 insertions(+), 5276 deletions(-) create mode 100644 crates/collab/src/integration_tests.rs diff --git a/crates/collab/src/integration_tests.rs b/crates/collab/src/integration_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..9500027e7aa67c2e2281571dbb7450513d84901f --- /dev/null +++ b/crates/collab/src/integration_tests.rs @@ -0,0 +1,5216 @@ +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 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", + "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.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::>(), + ["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"] + ); + }); + + 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::>(), + ["a.txt", "b.txt"] + ); + }); + worktree_b.read_with(cx_b, |worktree, _| { + assert_eq!( + worktree + .paths() + .map(|p| p.to_string_lossy()) + .collect::>(), + ["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/main.rs b/crates/collab/src/main.rs index 47f1e6173a3b740b5430a5e73d765efdc7871129..6cc494efe9b15b5605a1c59cd557a8a741d51a40 100644 --- a/crates/collab/src/main.rs +++ b/crates/collab/src/main.rs @@ -4,6 +4,9 @@ mod db; mod env; mod rpc; +#[cfg(test)] +mod integration_tests; + use axum::{body::Body, Router}; use collab::{Error, Result}; use db::{Db, PostgresDb}; diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 18b9768f1156ffd93a5ddbfe84ecf53558e3341f..858684aee9eacfeb8e359eb0351a67c8be2b6ba6 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,7 +81,7 @@ impl Response { pub struct Server { peer: Arc, - store: RwLock, + pub(crate) store: RwLock, app_state: Arc, handlers: HashMap, notifications: Option>, @@ -1691,5277 +1692,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, - ) { - 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 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", - "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.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::>(), - ["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"] - ); - }); - - 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::>(), - ["a.txt", "b.txt"] - ); - }); - worktree_b.read_with(cx_b, |worktree, _| { - assert_eq!( - worktree - .paths() - .map(|p| p.to_string_lossy()) - .collect::>(), - ["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()) - } - } -}