Show cursors for remote participants (#4082)

Conrad Irwin created

This PR enables cursors for remote participants.

They are shown for 2 seconds when you focus a buffer, and then on hover.

Release Notes:

- Added usernames next to remote cursors

Change summary

crates/channel/src/channel_buffer.rs         | 12 ++
crates/client/src/user.rs                    | 45 ++++++++++
crates/collab/src/db/queries/buffers.rs      | 17 +++
crates/collab_ui/src/channel_view.rs         |  9 ++
crates/editor/src/editor.rs                  | 34 +++++++
crates/editor/src/element.rs                 | 96 +++++++++++++++++++++
crates/gpui/src/executor.rs                  |  2 
crates/terminal_view/src/terminal_element.rs |  1 
8 files changed, 209 insertions(+), 7 deletions(-)

Detailed changes

crates/channel/src/channel_buffer.rs 🔗

@@ -1,6 +1,6 @@
 use crate::{Channel, ChannelId, ChannelStore};
 use anyhow::Result;
-use client::{Client, Collaborator, UserStore};
+use client::{Client, Collaborator, UserStore, ZED_ALWAYS_ACTIVE};
 use collections::HashMap;
 use gpui::{AppContext, AsyncAppContext, Context, EventEmitter, Model, ModelContext, Task};
 use language::proto::serialize_version;
@@ -181,6 +181,16 @@ impl ChannelBuffer {
     ) {
         match event {
             language::Event::Operation(operation) => {
+                if *ZED_ALWAYS_ACTIVE {
+                    match operation {
+                        language::Operation::UpdateSelections { selections, .. } => {
+                            if selections.is_empty() {
+                                return;
+                            }
+                        }
+                        _ => {}
+                    }
+                }
                 let operation = language::proto::serialize_operation(operation);
                 self.client
                     .send(proto::UpdateChannelBuffer {

crates/client/src/user.rs 🔗

@@ -3,7 +3,10 @@ use anyhow::{anyhow, Context, Result};
 use collections::{hash_map::Entry, HashMap, HashSet};
 use feature_flags::FeatureFlagAppExt;
 use futures::{channel::mpsc, Future, StreamExt};
-use gpui::{AsyncAppContext, EventEmitter, Model, ModelContext, SharedUrl, Task};
+use gpui::{
+    AppContext, AsyncAppContext, EventEmitter, Model, ModelContext, SharedString, SharedUrl, Task,
+    WeakModel,
+};
 use postage::{sink::Sink, watch};
 use rpc::proto::{RequestMessage, UsersResponse};
 use std::sync::{Arc, Weak};
@@ -77,6 +80,7 @@ pub struct UserStore {
     client: Weak<Client>,
     _maintain_contacts: Task<()>,
     _maintain_current_user: Task<Result<()>>,
+    weak_self: WeakModel<Self>,
 }
 
 #[derive(Clone)]
@@ -194,6 +198,7 @@ impl UserStore {
                 Ok(())
             }),
             pending_contact_requests: Default::default(),
+            weak_self: cx.weak_model(),
         }
     }
 
@@ -579,6 +584,19 @@ impl UserStore {
         self.users.get(&user_id).cloned()
     }
 
+    pub fn get_user_optimistic(
+        &mut self,
+        user_id: u64,
+        cx: &mut ModelContext<Self>,
+    ) -> Option<Arc<User>> {
+        if let Some(user) = self.users.get(&user_id).cloned() {
+            return Some(user);
+        }
+
+        self.get_user(user_id, cx).detach_and_log_err(cx);
+        None
+    }
+
     pub fn get_user(
         &mut self,
         user_id: u64,
@@ -651,6 +669,31 @@ impl UserStore {
     pub fn participant_indices(&self) -> &HashMap<u64, ParticipantIndex> {
         &self.participant_indices
     }
+
+    pub fn participant_names(
+        &self,
+        user_ids: impl Iterator<Item = u64>,
+        cx: &AppContext,
+    ) -> HashMap<u64, SharedString> {
+        let mut ret = HashMap::default();
+        let mut missing_user_ids = Vec::new();
+        for id in user_ids {
+            if let Some(github_login) = self.get_cached_user(id).map(|u| u.github_login.clone()) {
+                ret.insert(id, github_login.into());
+            } else {
+                missing_user_ids.push(id)
+            }
+        }
+        if !missing_user_ids.is_empty() {
+            let this = self.weak_self.clone();
+            cx.spawn(|mut cx| async move {
+                this.update(&mut cx, |this, cx| this.get_users(missing_user_ids, cx))?
+                    .await
+            })
+            .detach_and_log_err(cx);
+        }
+        ret
+    }
 }
 
 impl User {

crates/collab/src/db/queries/buffers.rs 🔗

@@ -450,8 +450,21 @@ impl Database {
     )> {
         self.transaction(move |tx| async move {
             let channel = self.get_channel_internal(channel_id, &*tx).await?;
-            self.check_user_is_channel_member(&channel, user, &*tx)
-                .await?;
+
+            let mut requires_write_permission = false;
+            for op in operations.iter() {
+                match op.variant {
+                    None | Some(proto::operation::Variant::UpdateSelections(_)) => {}
+                    Some(_) => requires_write_permission = true,
+                }
+            }
+            if requires_write_permission {
+                self.check_user_is_channel_member(&channel, user, &*tx)
+                    .await?;
+            } else {
+                self.check_user_is_channel_participant(&channel, user, &*tx)
+                    .await?;
+            }
 
             let buffer = buffer::Entity::find()
                 .filter(buffer::Column::ChannelId.eq(channel_id))

crates/collab_ui/src/channel_view.rs 🔗

@@ -442,4 +442,13 @@ impl CollaborationHub for ChannelBufferCollaborationHub {
     ) -> &'a HashMap<u64, ParticipantIndex> {
         self.0.read(cx).user_store().read(cx).participant_indices()
     }
+
+    fn user_names(&self, cx: &AppContext) -> HashMap<u64, SharedString> {
+        let user_ids = self.collaborators(cx).values().map(|c| c.user_id);
+        self.0
+            .read(cx)
+            .user_store()
+            .read(cx)
+            .participant_names(user_ids, cx)
+    }
 }

crates/editor/src/editor.rs 🔗

@@ -367,6 +367,8 @@ pub struct Editor {
     project: Option<Model<Project>>,
     collaboration_hub: Option<Box<dyn CollaborationHub>>,
     blink_manager: Model<BlinkManager>,
+    recently_focused: bool,
+    hovered_cursor: Option<HoveredCursor>,
     pub show_local_selections: bool,
     mode: EditorMode,
     show_gutter: bool,
@@ -418,6 +420,7 @@ pub struct EditorSnapshot {
     ongoing_scroll: OngoingScroll,
 }
 
+#[derive(Debug)]
 pub struct RemoteSelection {
     pub replica_id: ReplicaId,
     pub selection: Selection<Anchor>,
@@ -425,6 +428,7 @@ pub struct RemoteSelection {
     pub peer_id: PeerId,
     pub line_mode: bool,
     pub participant_index: Option<ParticipantIndex>,
+    pub user_name: Option<SharedString>,
 }
 
 #[derive(Clone, Debug)]
@@ -441,6 +445,11 @@ enum SelectionHistoryMode {
     Redoing,
 }
 
+struct HoveredCursor {
+    replica_id: u16,
+    selection_id: usize,
+}
+
 impl Default for SelectionHistoryMode {
     fn default() -> Self {
         Self::Normal
@@ -1604,6 +1613,8 @@ impl Editor {
             pixel_position_of_newest_cursor: None,
             gutter_width: Default::default(),
             style: None,
+            recently_focused: false,
+            hovered_cursor: Default::default(),
             editor_actions: Default::default(),
             show_copilot_suggestions: mode == EditorMode::Full,
             _subscriptions: vec![
@@ -8992,6 +9003,17 @@ impl Editor {
             cx.focus(&rename_editor_focus_handle);
         } else {
             self.blink_manager.update(cx, BlinkManager::enable);
+            self.recently_focused = true;
+            cx.notify();
+            cx.spawn(|this, mut cx| async move {
+                cx.background_executor().timer(Duration::from_secs(2)).await;
+                this.update(&mut cx, |this, cx| {
+                    this.recently_focused = false;
+                    cx.notify()
+                })
+                .ok()
+            })
+            .detach();
             self.buffer.update(cx, |buffer, cx| {
                 buffer.finalize_last_transaction(cx);
                 if self.leader_peer_id.is_none() {
@@ -9043,6 +9065,7 @@ pub trait CollaborationHub {
         &self,
         cx: &'a AppContext,
     ) -> &'a HashMap<u64, ParticipantIndex>;
+    fn user_names(&self, cx: &AppContext) -> HashMap<u64, SharedString>;
 }
 
 impl CollaborationHub for Model<Project> {
@@ -9056,6 +9079,14 @@ impl CollaborationHub for Model<Project> {
     ) -> &'a HashMap<u64, ParticipantIndex> {
         self.read(cx).user_store().read(cx).participant_indices()
     }
+
+    fn user_names(&self, cx: &AppContext) -> HashMap<u64, SharedString> {
+        let this = self.read(cx);
+        let user_ids = this.collaborators().values().map(|c| c.user_id);
+        this.user_store().read_with(cx, |user_store, cx| {
+            user_store.participant_names(user_ids, cx)
+        })
+    }
 }
 
 fn inlay_hint_settings(
@@ -9107,6 +9138,7 @@ impl EditorSnapshot {
         collaboration_hub: &dyn CollaborationHub,
         cx: &'a AppContext,
     ) -> impl 'a + Iterator<Item = RemoteSelection> {
+        let participant_names = collaboration_hub.user_names(cx);
         let participant_indices = collaboration_hub.user_participant_indices(cx);
         let collaborators_by_peer_id = collaboration_hub.collaborators(cx);
         let collaborators_by_replica_id = collaborators_by_peer_id
@@ -9118,6 +9150,7 @@ impl EditorSnapshot {
             .filter_map(move |(replica_id, line_mode, cursor_shape, selection)| {
                 let collaborator = collaborators_by_replica_id.get(&replica_id)?;
                 let participant_index = participant_indices.get(&collaborator.user_id).copied();
+                let user_name = participant_names.get(&collaborator.user_id).cloned();
                 Some(RemoteSelection {
                     replica_id,
                     selection,
@@ -9125,6 +9158,7 @@ impl EditorSnapshot {
                     line_mode,
                     participant_index,
                     peer_id: collaborator.peer_id,
+                    user_name,
                 })
             })
     }

crates/editor/src/element.rs 🔗

@@ -64,6 +64,7 @@ struct SelectionLayout {
     is_local: bool,
     range: Range<DisplayPoint>,
     active_rows: Range<u32>,
+    user_name: Option<SharedString>,
 }
 
 impl SelectionLayout {
@@ -74,6 +75,7 @@ impl SelectionLayout {
         map: &DisplaySnapshot,
         is_newest: bool,
         is_local: bool,
+        user_name: Option<SharedString>,
     ) -> Self {
         let point_selection = selection.map(|p| p.to_point(&map.buffer_snapshot));
         let display_selection = point_selection.map(|p| p.to_display_point(map));
@@ -113,6 +115,7 @@ impl SelectionLayout {
             is_local,
             range,
             active_rows,
+            user_name,
         }
     }
 }
@@ -564,6 +567,7 @@ impl EditorElement {
                         cx,
                     );
                     hover_at(editor, Some(point), cx);
+                    Self::update_visible_cursor(editor, point, cx);
                 }
                 None => {
                     update_inlay_link_and_hover_points(
@@ -585,6 +589,39 @@ impl EditorElement {
         }
     }
 
+    fn update_visible_cursor(
+        editor: &mut Editor,
+        point: DisplayPoint,
+        cx: &mut ViewContext<Editor>,
+    ) {
+        let snapshot = editor.snapshot(cx);
+        let Some(hub) = editor.collaboration_hub() else {
+            return;
+        };
+        let range = DisplayPoint::new(point.row(), point.column().saturating_sub(1))
+            ..DisplayPoint::new(
+                point.row(),
+                (point.column() + 1).min(snapshot.line_len(point.row())),
+            );
+
+        let range = snapshot
+            .buffer_snapshot
+            .anchor_at(range.start.to_point(&snapshot.display_snapshot), Bias::Left)
+            ..snapshot
+                .buffer_snapshot
+                .anchor_at(range.end.to_point(&snapshot.display_snapshot), Bias::Right);
+
+        let Some(selection) = snapshot.remote_selections_in_range(&range, hub, cx).next() else {
+            editor.hovered_cursor.take();
+            return;
+        };
+        editor.hovered_cursor.replace(crate::HoveredCursor {
+            replica_id: selection.replica_id,
+            selection_id: selection.selection.id,
+        });
+        cx.notify()
+    }
+
     fn paint_background(
         &self,
         gutter_bounds: Bounds<Pixels>,
@@ -982,8 +1019,10 @@ impl EditorElement {
                 let corner_radius = 0.15 * layout.position_map.line_height;
                 let mut invisible_display_ranges = SmallVec::<[Range<DisplayPoint>; 32]>::new();
 
-                for (selection_style, selections) in &layout.selections {
-                    for selection in selections {
+                for (participant_ix, (selection_style, selections)) in
+                    layout.selections.iter().enumerate()
+                {
+                    for selection in selections.into_iter() {
                         self.paint_highlighted_range(
                             selection.range.clone(),
                             selection_style.selection,
@@ -1064,6 +1103,7 @@ impl EditorElement {
                                         ))
                                     });
                                 }
+
                                 cursors.push(Cursor {
                                     color: selection_style.cursor,
                                     block_width,
@@ -1071,6 +1111,14 @@ impl EditorElement {
                                     line_height: layout.position_map.line_height,
                                     shape: selection.cursor_shape,
                                     block_text,
+                                    cursor_name: selection.user_name.clone().map(|name| {
+                                        CursorName {
+                                            string: name,
+                                            color: self.style.background,
+                                            is_top_row: cursor_position.row() == 0,
+                                            z_index: (participant_ix % 256).try_into().unwrap(),
+                                        }
+                                    }),
                                 });
                             }
                         }
@@ -1889,6 +1937,7 @@ impl EditorElement {
                         &snapshot.display_snapshot,
                         is_newest,
                         true,
+                        None,
                     );
                     if is_newest {
                         newest_selection_head = Some(layout.head);
@@ -1949,6 +1998,7 @@ impl EditorElement {
                     if Some(selection.peer_id) == editor.leader_peer_id {
                         continue;
                     }
+                    let is_shown = editor.recently_focused || editor.hovered_cursor.as_ref().is_some_and(|c| c.replica_id == selection.replica_id && c.selection_id == selection.selection.id);
 
                     remote_selections
                         .entry(selection.replica_id)
@@ -1961,6 +2011,11 @@ impl EditorElement {
                             &snapshot.display_snapshot,
                             false,
                             false,
+                            if is_shown {
+                                selection.user_name
+                            } else {
+                                None
+                            },
                         ));
                 }
 
@@ -1992,6 +2047,7 @@ impl EditorElement {
                     &snapshot.display_snapshot,
                     true,
                     true,
+                    None,
                 )
                 .head
             });
@@ -3097,6 +3153,15 @@ pub struct Cursor {
     color: Hsla,
     shape: CursorShape,
     block_text: Option<ShapedLine>,
+    cursor_name: Option<CursorName>,
+}
+
+#[derive(Debug)]
+pub struct CursorName {
+    string: SharedString,
+    color: Hsla,
+    is_top_row: bool,
+    z_index: u8,
 }
 
 impl Cursor {
@@ -3107,6 +3172,7 @@ impl Cursor {
         color: Hsla,
         shape: CursorShape,
         block_text: Option<ShapedLine>,
+        cursor_name: Option<CursorName>,
     ) -> Cursor {
         Cursor {
             origin,
@@ -3115,6 +3181,7 @@ impl Cursor {
             color,
             shape,
             block_text,
+            cursor_name,
         }
     }
 
@@ -3150,6 +3217,31 @@ impl Cursor {
             fill(bounds, self.color)
         };
 
+        if let Some(name) = &self.cursor_name {
+            let text_size = self.line_height / 1.5;
+
+            let name_origin = if name.is_top_row {
+                point(bounds.right() - px(1.), bounds.top())
+            } else {
+                point(bounds.left(), bounds.top() - text_size / 2. - px(1.))
+            };
+            cx.with_z_index(name.z_index, |cx| {
+                div()
+                    .bg(self.color)
+                    .text_size(text_size)
+                    .px_0p5()
+                    .line_height(text_size + px(2.))
+                    .text_color(name.color)
+                    .child(name.string.clone())
+                    .into_any_element()
+                    .draw(
+                        name_origin,
+                        size(AvailableSpace::MinContent, AvailableSpace::MinContent),
+                        cx,
+                    )
+            })
+        }
+
         cx.paint_quad(cursor);
 
         if let Some(block_text) = &self.block_text {

crates/gpui/src/executor.rs 🔗

@@ -68,7 +68,7 @@ where
     /// Run the task to completion in the background and log any
     /// errors that occur.
     #[track_caller]
-    pub fn detach_and_log_err(self, cx: &mut AppContext) {
+    pub fn detach_and_log_err(self, cx: &AppContext) {
         let location = core::panic::Location::caller();
         cx.foreground_executor()
             .spawn(self.log_tracked_err(*location))