Automatically unfollow when editing, scrolling or changing selections

Antonio Scandurra created

Change summary

crates/editor/src/editor.rs                   |  44 +++--
crates/editor/src/items.rs                    |  18 +
crates/file_finder/src/file_finder.rs         |   2 
crates/go_to_line/src/go_to_line.rs           |   2 
crates/language/src/buffer.rs                 |  19 +-
crates/language/src/tests.rs                  |  12 +
crates/outline/src/outline.rs                 |   2 
crates/project/src/project.rs                 |  13 +
crates/project_symbols/src/project_symbols.rs |   2 
crates/search/src/buffer_search.rs            |   6 
crates/search/src/project_search.rs           |   2 
crates/server/src/rpc.rs                      | 148 +++++++++++++++++++-
crates/theme_selector/src/theme_selector.rs   |   2 
crates/workspace/src/workspace.rs             |   2 
14 files changed, 214 insertions(+), 60 deletions(-)

Detailed changes

crates/editor/src/editor.rs 🔗

@@ -1035,14 +1035,19 @@ impl Editor {
             self.scroll_top_anchor = Some(anchor);
         }
 
-        cx.emit(Event::ScrollPositionChanged);
+        cx.emit(Event::ScrollPositionChanged { local: true });
         cx.notify();
     }
 
-    fn set_scroll_top_anchor(&mut self, anchor: Option<Anchor>, cx: &mut ViewContext<Self>) {
+    fn set_scroll_top_anchor(
+        &mut self,
+        anchor: Option<Anchor>,
+        local: bool,
+        cx: &mut ViewContext<Self>,
+    ) {
         self.scroll_position = Vector2F::zero();
         self.scroll_top_anchor = anchor;
-        cx.emit(Event::ScrollPositionChanged);
+        cx.emit(Event::ScrollPositionChanged { local });
         cx.notify();
     }
 
@@ -1267,7 +1272,7 @@ impl Editor {
             _ => {}
         }
 
-        self.set_selections(self.selections.clone(), Some(pending), cx);
+        self.set_selections(self.selections.clone(), Some(pending), true, cx);
     }
 
     fn begin_selection(
@@ -1347,7 +1352,12 @@ impl Editor {
         } else {
             selections = Arc::from([]);
         }
-        self.set_selections(selections, Some(PendingSelection { selection, mode }), cx);
+        self.set_selections(
+            selections,
+            Some(PendingSelection { selection, mode }),
+            true,
+            cx,
+        );
 
         cx.notify();
     }
@@ -1461,7 +1471,7 @@ impl Editor {
                 pending.selection.end = buffer.anchor_before(head);
                 pending.selection.reversed = false;
             }
-            self.set_selections(self.selections.clone(), Some(pending), cx);
+            self.set_selections(self.selections.clone(), Some(pending), true, cx);
         } else {
             log::error!("update_selection dispatched with no pending selection");
             return;
@@ -1548,7 +1558,7 @@ impl Editor {
             if selections.is_empty() {
                 selections = Arc::from([pending.selection]);
             }
-            self.set_selections(selections, None, cx);
+            self.set_selections(selections, None, true, cx);
             self.request_autoscroll(Autoscroll::Fit, cx);
         } else {
             let mut oldest_selection = self.oldest_selection::<usize>(&cx);
@@ -1895,7 +1905,7 @@ impl Editor {
                 }
                 drop(snapshot);
 
-                self.set_selections(selections.into(), None, cx);
+                self.set_selections(selections.into(), None, true, cx);
                 true
             }
         } else {
@@ -3294,7 +3304,7 @@ impl Editor {
     pub fn undo(&mut self, _: &Undo, cx: &mut ViewContext<Self>) {
         if let Some(tx_id) = self.buffer.update(cx, |buffer, cx| buffer.undo(cx)) {
             if let Some((selections, _)) = self.selection_history.get(&tx_id).cloned() {
-                self.set_selections(selections, None, cx);
+                self.set_selections(selections, None, true, cx);
             }
             self.request_autoscroll(Autoscroll::Fit, cx);
         }
@@ -3303,7 +3313,7 @@ impl Editor {
     pub fn redo(&mut self, _: &Redo, cx: &mut ViewContext<Self>) {
         if let Some(tx_id) = self.buffer.update(cx, |buffer, cx| buffer.redo(cx)) {
             if let Some((_, Some(selections))) = self.selection_history.get(&tx_id).cloned() {
-                self.set_selections(selections, None, cx);
+                self.set_selections(selections, None, true, cx);
             }
             self.request_autoscroll(Autoscroll::Fit, cx);
         }
@@ -4967,6 +4977,7 @@ impl Editor {
                 }
             })),
             None,
+            true,
             cx,
         );
     }
@@ -5027,6 +5038,7 @@ impl Editor {
         &mut self,
         selections: Arc<[Selection<Anchor>]>,
         pending_selection: Option<PendingSelection>,
+        local: bool,
         cx: &mut ViewContext<Self>,
     ) {
         assert!(
@@ -5095,7 +5107,7 @@ impl Editor {
         self.refresh_document_highlights(cx);
 
         self.pause_cursor_blinking(cx);
-        cx.emit(Event::SelectionsChanged);
+        cx.emit(Event::SelectionsChanged { local });
     }
 
     pub fn request_autoscroll(&mut self, autoscroll: Autoscroll, cx: &mut ViewContext<Self>) {
@@ -5508,10 +5520,10 @@ impl Editor {
         cx: &mut ViewContext<Self>,
     ) {
         match event {
-            language::Event::Edited => {
+            language::Event::Edited { local } => {
                 self.refresh_active_diagnostics(cx);
                 self.refresh_code_actions(cx);
-                cx.emit(Event::Edited);
+                cx.emit(Event::Edited { local: *local });
             }
             language::Event::Dirtied => cx.emit(Event::Dirtied),
             language::Event::Saved => cx.emit(Event::Saved),
@@ -5638,13 +5650,13 @@ fn compute_scroll_position(
 #[derive(Copy, Clone)]
 pub enum Event {
     Activate,
-    Edited,
+    Edited { local: bool },
     Blurred,
     Dirtied,
     Saved,
     TitleChanged,
-    SelectionsChanged,
-    ScrollPositionChanged,
+    SelectionsChanged { local: bool },
+    ScrollPositionChanged { local: bool },
     Closed,
 }
 

crates/editor/src/items.rs 🔗

@@ -58,7 +58,7 @@ impl FollowableItem for Editor {
                                 .collect::<Vec<_>>()
                         };
                         if !selections.is_empty() {
-                            editor.set_selections(selections.into(), None, cx);
+                            editor.set_selections(selections.into(), None, false, cx);
                         }
                         editor
                     })
@@ -104,7 +104,7 @@ impl FollowableItem for Editor {
         _: &AppContext,
     ) -> Option<update_view::Variant> {
         match event {
-            Event::ScrollPositionChanged | Event::SelectionsChanged => {
+            Event::ScrollPositionChanged { .. } | Event::SelectionsChanged { .. } => {
                 Some(update_view::Variant::Editor(update_view::Editor {
                     scroll_top: self
                         .scroll_top_anchor
@@ -138,10 +138,11 @@ impl FollowableItem for Editor {
                             text_anchor: language::proto::deserialize_anchor(anchor)
                                 .ok_or_else(|| anyhow!("invalid scroll top"))?,
                         }),
+                        false,
                         cx,
                     );
                 } else {
-                    self.set_scroll_top_anchor(None, cx);
+                    self.set_scroll_top_anchor(None, false, cx);
                 }
 
                 let selections = message
@@ -152,15 +153,20 @@ impl FollowableItem for Editor {
                     })
                     .collect::<Vec<_>>();
                 if !selections.is_empty() {
-                    self.set_selections(selections.into(), None, cx);
+                    self.set_selections(selections.into(), None, false, cx);
                 }
             }
         }
         Ok(())
     }
 
-    fn should_unfollow_on_event(event: &Self::Event, cx: &AppContext) -> bool {
-        false
+    fn should_unfollow_on_event(event: &Self::Event, _: &AppContext) -> bool {
+        match event {
+            Event::Edited { local } => *local,
+            Event::SelectionsChanged { local } => *local,
+            Event::ScrollPositionChanged { local } => *local,
+            _ => false,
+        }
     }
 }
 

crates/file_finder/src/file_finder.rs 🔗

@@ -291,7 +291,7 @@ impl FileFinder {
         cx: &mut ViewContext<Self>,
     ) {
         match event {
-            editor::Event::Edited => {
+            editor::Event::Edited { .. } => {
                 let query = self.query_editor.update(cx, |buffer, cx| buffer.text(cx));
                 if query.is_empty() {
                     self.latest_search_id = post_inc(&mut self.search_count);

crates/go_to_line/src/go_to_line.rs 🔗

@@ -102,7 +102,7 @@ impl GoToLine {
     ) {
         match event {
             editor::Event::Blurred => cx.emit(Event::Dismissed),
-            editor::Event::Edited => {
+            editor::Event::Edited { .. } => {
                 let line_editor = self.line_editor.read(cx).buffer().read(cx).read(cx).text();
                 let mut components = line_editor.trim().split(&[',', ':'][..]);
                 let row = components.next().and_then(|row| row.parse::<u32>().ok());

crates/language/src/buffer.rs 🔗

@@ -142,7 +142,7 @@ pub enum Operation {
 #[derive(Clone, Debug, PartialEq, Eq)]
 pub enum Event {
     Operation(Operation),
-    Edited,
+    Edited { local: bool },
     Dirtied,
     Saved,
     FileHandleChanged,
@@ -968,7 +968,7 @@ impl Buffer {
     ) -> Option<TransactionId> {
         if let Some((transaction_id, start_version)) = self.text.end_transaction_at(now) {
             let was_dirty = start_version != self.saved_version;
-            self.did_edit(&start_version, was_dirty, cx);
+            self.did_edit(&start_version, was_dirty, true, cx);
             Some(transaction_id)
         } else {
             None
@@ -1161,6 +1161,7 @@ impl Buffer {
         &mut self,
         old_version: &clock::Global,
         was_dirty: bool,
+        local: bool,
         cx: &mut ModelContext<Self>,
     ) {
         if self.edits_since::<usize>(old_version).next().is_none() {
@@ -1169,7 +1170,7 @@ impl Buffer {
 
         self.reparse(cx);
 
-        cx.emit(Event::Edited);
+        cx.emit(Event::Edited { local });
         if !was_dirty {
             cx.emit(Event::Dirtied);
         }
@@ -1206,7 +1207,7 @@ impl Buffer {
         self.text.apply_ops(buffer_ops)?;
         self.deferred_ops.insert(deferred_ops);
         self.flush_deferred_ops(cx);
-        self.did_edit(&old_version, was_dirty, cx);
+        self.did_edit(&old_version, was_dirty, false, cx);
         // Notify independently of whether the buffer was edited as the operations could include a
         // selection update.
         cx.notify();
@@ -1321,7 +1322,7 @@ impl Buffer {
 
         if let Some((transaction_id, operation)) = self.text.undo() {
             self.send_operation(Operation::Buffer(operation), cx);
-            self.did_edit(&old_version, was_dirty, cx);
+            self.did_edit(&old_version, was_dirty, true, cx);
             Some(transaction_id)
         } else {
             None
@@ -1342,7 +1343,7 @@ impl Buffer {
             self.send_operation(Operation::Buffer(operation), cx);
         }
         if undone {
-            self.did_edit(&old_version, was_dirty, cx)
+            self.did_edit(&old_version, was_dirty, true, cx)
         }
         undone
     }
@@ -1353,7 +1354,7 @@ impl Buffer {
 
         if let Some((transaction_id, operation)) = self.text.redo() {
             self.send_operation(Operation::Buffer(operation), cx);
-            self.did_edit(&old_version, was_dirty, cx);
+            self.did_edit(&old_version, was_dirty, true, cx);
             Some(transaction_id)
         } else {
             None
@@ -1374,7 +1375,7 @@ impl Buffer {
             self.send_operation(Operation::Buffer(operation), cx);
         }
         if redone {
-            self.did_edit(&old_version, was_dirty, cx)
+            self.did_edit(&old_version, was_dirty, true, cx)
         }
         redone
     }
@@ -1440,7 +1441,7 @@ impl Buffer {
         if !ops.is_empty() {
             for op in ops {
                 self.send_operation(Operation::Buffer(op), cx);
-                self.did_edit(&old_version, was_dirty, cx);
+                self.did_edit(&old_version, was_dirty, true, cx);
             }
         }
     }

crates/language/src/tests.rs 🔗

@@ -122,11 +122,19 @@ fn test_edit_events(cx: &mut gpui::MutableAppContext) {
     let buffer_1_events = buffer_1_events.borrow();
     assert_eq!(
         *buffer_1_events,
-        vec![Event::Edited, Event::Dirtied, Event::Edited, Event::Edited]
+        vec![
+            Event::Edited { local: true },
+            Event::Dirtied,
+            Event::Edited { local: true },
+            Event::Edited { local: true }
+        ]
     );
 
     let buffer_2_events = buffer_2_events.borrow();
-    assert_eq!(*buffer_2_events, vec![Event::Edited, Event::Dirtied]);
+    assert_eq!(
+        *buffer_2_events,
+        vec![Event::Edited { local: false }, Event::Dirtied]
+    );
 }
 
 #[gpui::test]

crates/outline/src/outline.rs 🔗

@@ -224,7 +224,7 @@ impl OutlineView {
     ) {
         match event {
             editor::Event::Blurred => cx.emit(Event::Dismissed),
-            editor::Event::Edited => self.update_matches(cx),
+            editor::Event::Edited { .. } => self.update_matches(cx),
             _ => {}
         }
     }

crates/project/src/project.rs 🔗

@@ -1178,7 +1178,7 @@ impl Project {
                 });
                 cx.background().spawn(request).detach_and_log_err(cx);
             }
-            BufferEvent::Edited => {
+            BufferEvent::Edited { .. } => {
                 let language_server = self
                     .language_server_for_buffer(buffer.read(cx), cx)?
                     .clone();
@@ -6227,7 +6227,10 @@ mod tests {
             assert!(buffer.is_dirty());
             assert_eq!(
                 *events.borrow(),
-                &[language::Event::Edited, language::Event::Dirtied]
+                &[
+                    language::Event::Edited { local: true },
+                    language::Event::Dirtied
+                ]
             );
             events.borrow_mut().clear();
             buffer.did_save(buffer.version(), buffer.file().unwrap().mtime(), None, cx);
@@ -6250,9 +6253,9 @@ mod tests {
             assert_eq!(
                 *events.borrow(),
                 &[
-                    language::Event::Edited,
+                    language::Event::Edited { local: true },
                     language::Event::Dirtied,
-                    language::Event::Edited,
+                    language::Event::Edited { local: true },
                 ],
             );
             events.borrow_mut().clear();
@@ -6264,7 +6267,7 @@ mod tests {
             assert!(buffer.is_dirty());
         });
 
-        assert_eq!(*events.borrow(), &[language::Event::Edited]);
+        assert_eq!(*events.borrow(), &[language::Event::Edited { local: true }]);
 
         // When a file is deleted, the buffer is considered dirty.
         let events = Rc::new(RefCell::new(Vec::new()));

crates/project_symbols/src/project_symbols.rs 🔗

@@ -328,7 +328,7 @@ impl ProjectSymbolsView {
     ) {
         match event {
             editor::Event::Blurred => cx.emit(Event::Dismissed),
-            editor::Event::Edited => self.update_matches(cx),
+            editor::Event::Edited { .. } => self.update_matches(cx),
             _ => {}
         }
     }

crates/search/src/buffer_search.rs 🔗

@@ -360,7 +360,7 @@ impl SearchBar {
         cx: &mut ViewContext<Self>,
     ) {
         match event {
-            editor::Event::Edited => {
+            editor::Event::Edited { .. } => {
                 self.query_contains_error = false;
                 self.clear_matches(cx);
                 self.update_matches(true, cx);
@@ -377,8 +377,8 @@ impl SearchBar {
         cx: &mut ViewContext<Self>,
     ) {
         match event {
-            editor::Event::Edited => self.update_matches(false, cx),
-            editor::Event::SelectionsChanged => self.update_match_index(cx),
+            editor::Event::Edited { .. } => self.update_matches(false, cx),
+            editor::Event::SelectionsChanged { .. } => self.update_match_index(cx),
             _ => {}
         }
     }

crates/search/src/project_search.rs 🔗

@@ -350,7 +350,7 @@ impl ProjectSearchView {
         cx.observe(&results_editor, |_, _, cx| cx.emit(ViewEvent::UpdateTab))
             .detach();
         cx.subscribe(&results_editor, |this, _, event, cx| {
-            if matches!(event, editor::Event::SelectionsChanged) {
+            if matches!(event, editor::Event::SelectionsChanged { .. }) {
                 this.update_match_index(cx);
             }
         })

crates/server/src/rpc.rs 🔗

@@ -1086,7 +1086,7 @@ mod tests {
         self, ConfirmCodeAction, ConfirmCompletion, ConfirmRename, Editor, Input, Redo, Rename,
         ToOffset, ToggleCodeActions, Undo,
     };
-    use gpui::{executor, ModelHandle, TestAppContext, ViewHandle};
+    use gpui::{executor, geometry::vector::vec2f, ModelHandle, TestAppContext, ViewHandle};
     use language::{
         tree_sitter_rust, Diagnostic, DiagnosticEntry, Language, LanguageConfig, LanguageRegistry,
         LanguageServerConfig, OffsetRangeExt, Point, ToLspPosition,
@@ -4308,11 +4308,6 @@ mod tests {
                 .project_path(cx)),
             Some((worktree_id, "2.txt").into())
         );
-        let editor_b2 = workspace_b
-            .read_with(cx_b, |workspace, cx| workspace.active_item(cx))
-            .unwrap()
-            .downcast::<Editor>()
-            .unwrap();
 
         // When client A activates a different editor, client B does so as well.
         workspace_a.update(cx_a, |workspace, cx| {
@@ -4324,7 +4319,7 @@ mod tests {
             })
             .await;
 
-        // When client A selects something, client B does as well.
+        // Changes to client A's editor are reflected on client B.
         editor_a1.update(cx_a, |editor, cx| {
             editor.select_ranges([1..1, 2..2], None, cx);
         });
@@ -4334,17 +4329,26 @@ mod tests {
             })
             .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.select_ranges([3..3], None, cx);
+        });
+        editor_b1
+            .condition(cx_b, |editor, cx| editor.selected_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);
-            editor_a2.update(cx, |editor, cx| editor.set_text("TWO", cx));
+            workspace.activate_item(&editor_a2, cx)
         });
-        editor_b2
-            .condition(cx_b, |editor, cx| editor.text(cx) == "TWO")
-            .await;
+        cx_a.foreground().run_until_parked();
         assert_eq!(
             workspace_b.read_with(cx_b, |workspace, cx| workspace
                 .active_item(cx)
@@ -4456,6 +4460,126 @@ 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;
+        let mut client_a = server.create_client(cx_a, "user_a").await;
+        let mut client_b = server.create_client(cx_b, "user_b").await;
+        cx_a.update(editor::init);
+        cx_b.update(editor::init);
+
+        // Client A shares a project.
+        fs.insert_tree(
+            "/a",
+            json!({
+                ".zed.toml": r#"collaborators = ["user_b"]"#,
+                "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;
+        project_a
+            .update(cx_a, |project, cx| project.share(cx))
+            .await
+            .unwrap();
+
+        // Client B joins the project.
+        let project_b = client_b
+            .build_remote_project(
+                project_a
+                    .read_with(cx_a, |project, _| project.remote_id())
+                    .unwrap(),
+                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"), cx)
+            })
+            .await
+            .unwrap()
+            .downcast::<Editor>()
+            .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(&leader_id.into(), 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::<Editor>()
+                .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(&leader_id.into(), 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(&leader_id.into(), 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
+        );
+    }
+
     #[gpui::test(iterations = 100)]
     async fn test_random_collaboration(cx: &mut TestAppContext, rng: StdRng) {
         cx.foreground().forbid_parking();

crates/theme_selector/src/theme_selector.rs 🔗

@@ -204,7 +204,7 @@ impl ThemeSelector {
         cx: &mut ViewContext<Self>,
     ) {
         match event {
-            editor::Event::Edited => {
+            editor::Event::Edited { .. } => {
                 self.update_matches(cx);
                 self.select_if_matching(&cx.global::<Settings>().theme.name);
                 self.show_selected_theme(cx);

crates/workspace/src/workspace.rs 🔗

@@ -1750,7 +1750,7 @@ impl Workspace {
         None
     }
 
-    fn leader_for_pane(&self, pane: &ViewHandle<Pane>) -> Option<PeerId> {
+    pub fn leader_for_pane(&self, pane: &ViewHandle<Pane>) -> Option<PeerId> {
         self.follower_states_by_leader
             .iter()
             .find_map(|(leader_id, state)| {