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 e97c529faf..dc0b687170 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 ee202ee4a8..4a3dabfc71 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 0000000000..518530c539 --- /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 74401699ca..47f1e6173a 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 fc13d2927c..dd2e6a132a 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 e5a80e44f4..a4761ddb06 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 355d1f4433..9893f94292 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 0d8cbf1c6b..47337aa9a2 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 0b8824be80..6dd1b0685b 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 07fa2cd9b5..7d9ac8ed40 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 647481367a..ccb3d382ea 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 312b192cb9..d0a10df5a8 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 527c13bfe8..3bc9f4b9dc 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 0fee451c0d..91935f2be6 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 f9dfc588e1..f3b6115c84 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 a38d10c8f8..8ab485b58c 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 b4d5cbe9c7..31c2336f5e 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 f0731edd49..115ef9ea38 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 4d32d38c30..da9bc04cb1 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 dd2e6a132a..e5fdf44d3e 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 9893f94292..a794ac7edd 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 7d9ac8ed40..dfed550777 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 f3b6115c84..1d47731384 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 31c2336f5e..a5ae5448fb 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 115ef9ea38..115536e6a5 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 da9bc04cb1..0a7517bfb8 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 a4761ddb06..219fcba22b 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 8124f8a200..0636b4b1ef 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 e5fdf44d3e..00e7fdba2c 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 219fcba22b..d80b03da9e 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 a794ac7edd..3ef169a2e0 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 8dcc3fc7f1..fd8d57ca9f 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 2a391676fa..d9b5d470e7 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 0636b4b1ef..7f417fd31e 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 b44f0a1f34..cea607e9f3 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 0000000000..1cd5f13608 --- /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 115536e6a5..00ef989874 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 0a7517bfb8..480c7e07b6 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 f9080e554c..a319d6dbea 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 480c7e07b6..c3416da471 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 00e7fdba2c..f1dca985ab 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 3de44e0315..f76d23a187 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 d80b03da9e..f46fa83141 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 3ef169a2e0..8c0791517d 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 dfed550777..db6571cee1 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 eb4b9650a6..93e5f8279d 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 8ab485b58c..16533f89f1 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 d9b5d470e7..0d68b29bf9 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 0000000000..17a9e47d3d --- /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 a5ae5448fb..c4c6d4850b 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 00ef989874..5ee3f3d38b 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 a319d6dbea..b6120848a3 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 c3416da471..17a4272117 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 3e21e454f2..40ef2a84ab 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 0d68b29bf9..48c4ad339a 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 c4c6d4850b..e0ecbc33ad 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 5ee3f3d38b..c5ab4118f7 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 | 19 ++- 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, 141 insertions(+), 178 deletions(-) diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index f1dca985ab..c1e5d7db8c 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 f76d23a187..4378db5407 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); + 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 range.is_empty() && range.start.row > 0 { - range.start.row -= 1; - range.start.column = self.buffer_snapshot.line_len(range.start.row); + 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 f46fa83141..37080789d4 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 db6571cee1..b77e55c5cf 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 1d47731384..092d369058 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 16533f89f1..221898c056 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 48c4ad339a..2633683881 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 e0ecbc33ad..a08b8bd2d2 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 1cd5f13608..cb6a736c63 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 c5ab4118f7..89647b56e2 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 b6120848a3..b4a93e158c 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 17a4272117..665b468b73 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); + copy_selections_content(editor, editor.selections.line_mode, cx); + editor.edit_with_autoindent(edits, cx); + editor.change_selections(Some(Autoscroll::Fit), cx, |s| { + s.select_anchors(new_selections); + }); }); vim.switch_mode(Mode::Insert, cx); }); @@ -92,9 +93,14 @@ pub fn delete(_: &mut Workspace, _: &VisualDelete, cx: &mut ViewContext = Default::default(); + let line_mode = editor.selections.line_mode; editor.change_selections(Some(Autoscroll::Fit), cx, |s| { s.move_with(|map, selection| { - if !selection.reversed { + 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; @@ -102,60 +108,19 @@ 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(); - 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; - 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); - 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(); 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 | 288 +++++++++++--------------- crates/editor/src/test.rs | 312 ++++++++++++++++++++++++++++- crates/vim/src/vim_test_context.rs | 263 +++--------------------- 3 files changed, 449 insertions(+), 414 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 37080789d4..bb181a6ea1 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)); + async fn test_indent_outdent(cx: &mut gpui::TestAppContext) { + let mut cx = EditorTestContext::new(cx).await; - view.update(cx, |view, cx| { - // two selections on the same line - select_ranges( - view, - indoc! {" - [one] [two] - three - four"}, - cx, - ); + 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"}); - // indent from mid-tabstop to full tabstop - view.tab(&Tab, 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"}); - // 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 + 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"}); - // select across line ending - select_ranges( - view, - indoc! {" - one two - t[hree - ] four"}, - cx, - ); + cx.update_editor(|e, cx| e.tab_prev(&TabPrev, cx)); + cx.assert_editor_state(indoc! {" + one two + t[hree + } four"}); - // 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. + 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"}); - // 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, - ); - - 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.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) - }); + 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"}); - 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"); + // 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"}); - 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"); - }); + // 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)); + async fn test_delete(cx: &mut gpui::TestAppContext) { + let mut cx = EditorTestContext::new(cx).await; - 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); - }); + 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"}); - assert_eq!( - buffer.read(cx).read(cx).text(), - "on two three\nfou five six\nseven ten\n" - ); + // 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 cb064be545..a8bcc94ee2 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 b4a93e158c..52d4778b38 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 | 126 ++++++++-------- crates/util/src/test/marked_text.rs | 98 +++++++++---- 3 files changed, 217 insertions(+), 227 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index bb181a6ea1..e7af70f303 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 a8bcc94ee2..4c9ceed9ae 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 mut empty_selections = Vec::new(); + let mut reverse_selections = Vec::new(); + let mut forward_selections = Vec::new(); - 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) - } - }); + 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 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) + } + } - 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) }); @@ -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 23ac35ce86..733feeb3f8 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 - .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"); + markers: Vec, +) -> (String, HashMap>>) { + let all_markers = markers.iter().flat_map(|m| m.markers()).collect(); - 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 (unmarked_text, mut marker_offsets) = marked_text_by(marked_text, all_markers); + let range_lookup = markers + .into_iter() + .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::>>(); + (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 e7af70f303..b7ffe9ebbf 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 b77e55c5cf..db6571cee1 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 2633683881..55c9779581 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 665b468b73..3020db5e4c 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 175661aacf..27b666d6d0 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 b7ffe9ebbf..b07ca1df2e 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 092d369058..8837f264d3 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 b07ca1df2e..f4f3483641 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 8c0791517d..d5a59c2ecc 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 db6571cee1..7041062133 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 eb4b9650a6..2604848e3b 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 c4b68c0741..16a6481a43 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 26cde46c04..7ace58f428 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 518cefcd60..5d6848cd7b 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 a3d5cc5406..e22db89e3b 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 7ea925fce7..6371e51784 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 abcd667293..964ee1c97e 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 964ee1c97e..808561f110 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 8b97ef1a80..f6c516a445 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 e9f0efa311..fc8d3ba16e 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 cfe4ca0826..e90b716d02 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 6371e51784..51c2a42225 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 b1c4a1b51c..0000000000 --- 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 1049e216f3..831c076b5c 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 9b5ed12441..58c70d32c1 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 e90b716d02..b02e7204fb 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 c2f6c60ea6..aeba7551eb 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 b02e7204fb..faedbe9d45 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 faedbe9d45..cc5f128bc0 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 aeba7551eb..6ebe3dc35d 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 | 998 ++++++++++----------------------------- 1 file changed, 243 insertions(+), 755 deletions(-) diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 51c2a42225..18b9768f11 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)); + 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!({ @@ -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)); + 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 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", @@ -3813,38 +3468,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)); + 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 project_a = cx_a.update(|cx| { - Project::local( - client_a.clone(), - client_a.user_store.clone(), - lang_registry.clone(), - fs.clone(), - cx, - ) - }); - let (worktree_a, _) = project_a - .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/root-1", true, cx) - }) - .await - .unwrap(); - worktree_a - .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete()) - .await; - let worktree_id = worktree_a.read_with(cx_a, |tree, _| tree.id()); - - // Join the worktree as client B. + let (project_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)); + 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 project_a = cx_a.update(|cx| { - Project::local( - client_a.clone(), - client_a.user_store.clone(), - lang_registry.clone(), - fs.clone(), - cx, - ) - }); - let (worktree_a, _) = project_a - .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/root-1", true, cx) - }) - .await - .unwrap(); - worktree_a - .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete()) - .await; - let worktree_id = worktree_a.read_with(cx_a, |tree, _| tree.id()); - - // Join the worktree as client B. + let (project_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)]) + let (project_a, worktree_id) = client_a + .build_local_project(fs, "/code/crate-1", cx_a) .await; - - // Share a project as client A - let project_a = cx_a.update(|cx| { - Project::local( - client_a.clone(), - client_a.user_store.clone(), - lang_registry.clone(), - fs.clone(), - cx, - ) - }); - let (worktree_a, _) = project_a - .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/code/crate-1", true, cx) - }) - .await - .unwrap(); - worktree_a - .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete()) - .await; - let worktree_id = worktree_a.read_with(cx_a, |tree, _| tree.id()); - - // Join the worktree as client B. let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await; // Cause the language server to start. @@ -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)); + 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; + 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; - // Share a project as client A - let project_a = cx_a.update(|cx| { - Project::local( - client_a.clone(), - client_a.user_store.clone(), - lang_registry.clone(), - fs.clone(), - cx, - ) - }); - - let (worktree_a, _) = project_a - .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/root", true, cx) - }) - .await - .unwrap(); - worktree_a - .read_with(cx_a, |tree, _| tree.as_local().unwrap().scan_complete()) - .await; - let worktree_id = worktree_a.read_with(cx_a, |tree, _| tree.id()); - - // Join the project as client B. + let (project_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)); + 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!({ @@ -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)); + 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( "/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 0000000000..9500027e7a --- /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 47f1e6173a..6cc494efe9 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 18b9768f11..858684aee9 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()) - } - } -}