diff --git a/crates/channel/src/channel_buffer.rs b/crates/channel/src/channel_buffer.rs index 1aca05ec86..b5f4a06b97 100644 --- a/crates/channel/src/channel_buffer.rs +++ b/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 { diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index 4453bb40ea..dcab0e5394 100644 --- a/crates/client/src/user.rs +++ b/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, _maintain_contacts: Task<()>, _maintain_current_user: Task>, + weak_self: WeakModel, } #[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, + ) -> Option> { + 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 { &self.participant_indices } + + pub fn participant_names( + &self, + user_ids: impl Iterator, + cx: &AppContext, + ) -> HashMap { + 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 { diff --git a/crates/collab/src/db/queries/buffers.rs b/crates/collab/src/db/queries/buffers.rs index bdcaaab6ef..c19cd530a0 100644 --- a/crates/collab/src/db/queries/buffers.rs +++ b/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)) diff --git a/crates/collab_ui/src/channel_view.rs b/crates/collab_ui/src/channel_view.rs index 033889f771..b2c243dc89 100644 --- a/crates/collab_ui/src/channel_view.rs +++ b/crates/collab_ui/src/channel_view.rs @@ -442,4 +442,13 @@ impl CollaborationHub for ChannelBufferCollaborationHub { ) -> &'a HashMap { self.0.read(cx).user_store().read(cx).participant_indices() } + + fn user_names(&self, cx: &AppContext) -> HashMap { + 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) + } } diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 0e894c5f3f..8876feecef 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -367,6 +367,8 @@ pub struct Editor { project: Option>, collaboration_hub: Option>, blink_manager: Model, + recently_focused: bool, + hovered_cursor: Option, 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, @@ -425,6 +428,7 @@ pub struct RemoteSelection { pub peer_id: PeerId, pub line_mode: bool, pub participant_index: Option, + pub user_name: Option, } #[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; + fn user_names(&self, cx: &AppContext) -> HashMap; } impl CollaborationHub for Model { @@ -9056,6 +9079,14 @@ impl CollaborationHub for Model { ) -> &'a HashMap { self.read(cx).user_store().read(cx).participant_indices() } + + fn user_names(&self, cx: &AppContext) -> HashMap { + 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 { + 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, }) }) } diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 06faf3265e..6c6bfba190 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -64,6 +64,7 @@ struct SelectionLayout { is_local: bool, range: Range, active_rows: Range, + user_name: Option, } impl SelectionLayout { @@ -74,6 +75,7 @@ impl SelectionLayout { map: &DisplaySnapshot, is_newest: bool, is_local: bool, + user_name: Option, ) -> 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, + ) { + 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, @@ -982,8 +1019,10 @@ impl EditorElement { let corner_radius = 0.15 * layout.position_map.line_height; let mut invisible_display_ranges = SmallVec::<[Range; 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, + cursor_name: Option, +} + +#[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, + cursor_name: Option, ) -> 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 { diff --git a/crates/gpui/src/executor.rs b/crates/gpui/src/executor.rs index 9ed643a5ab..3e233854bc 100644 --- a/crates/gpui/src/executor.rs +++ b/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)) diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index 746a3716b8..42428c04f8 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -550,6 +550,7 @@ impl TerminalElement { theme.players().local().cursor, shape, text, + None, ) }, )