logiguard fork: GPUI xdg-activation keyboard-focus serial fix
Some checks failed
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled

Single-commit orphan branch: full zed-industries/zed @ 8c74db0 source tree
with a 3-file patch applied (no upstream history).

Patch (crates/gpui_linux/src/linux/wayland/):
  - serial.rs: add SerialKind::KeyboardEnter
  - client.rs: store wl_keyboard.enter serial; add latest_serial_of()
  - window.rs: activate() uses keyboard-enter serial (Mutter focus gate)

Mutter honors window activation only when the token carries the keyboard-
focus serial from wl_keyboard.enter; GPUI used a stale mouse-press serial.
See docs/tray-window-focus-wayland.md in logiguard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mohamad Khani
2026-07-14 02:22:17 +03:30
commit b72a46db68
3984 changed files with 1583326 additions and 0 deletions

View File

@@ -0,0 +1,78 @@
[package]
name = "collab_ui"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[lints]
workspace = true
[lib]
path = "src/collab_ui.rs"
doctest = false
[features]
default = []
test-support = [
"call/test-support",
"client/test-support",
"collections/test-support",
"editor/test-support",
"gpui/test-support",
"project/test-support",
"settings/test-support",
"util/test-support",
"workspace/test-support",
"title_bar/test-support",
]
[dependencies]
anyhow.workspace = true
call.workspace = true
channel.workspace = true
client.workspace = true
collections.workspace = true
db.workspace = true
editor.workspace = true
feature_flags.workspace = true
futures.workspace = true
fuzzy.workspace = true
gpui.workspace = true
livekit_client.workspace = true
menu.workspace = true
notifications.workspace = true
picker.workspace = true
project.workspace = true
release_channel.workspace = true
rpc.workspace = true
serde.workspace = true
serde_json.workspace = true
settings.workspace = true
smallvec.workspace = true
telemetry.workspace = true
theme.workspace = true
theme_settings.workspace = true
time.workspace = true
title_bar.workspace = true
ui.workspace = true
util.workspace = true
workspace.workspace = true
zed_actions.workspace = true
[dev-dependencies]
call = { workspace = true, features = ["test-support"] }
client = { workspace = true, features = ["test-support"] }
collections = { workspace = true, features = ["test-support"] }
editor = { workspace = true, features = ["test-support"] }
gpui = { workspace = true, features = ["test-support"] }
notifications = { workspace = true, features = ["test-support"] }
project = { workspace = true, features = ["test-support"] }
rpc = { workspace = true, features = ["test-support"] }
settings = { workspace = true, features = ["test-support"] }
util = { workspace = true, features = ["test-support"] }
workspace = { workspace = true, features = ["test-support"] }

View File

@@ -0,0 +1 @@
../../LICENSE-GPL

View File

@@ -0,0 +1,270 @@
use call::{ActiveCall, Room, room};
use gpui::{
DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, Render, Subscription,
Window,
};
use livekit_client::ConnectionQuality;
use ui::prelude::*;
use workspace::{ModalView, Workspace};
use zed_actions::ShowCallStats;
pub fn init(cx: &mut App) {
cx.observe_new(|workspace: &mut Workspace, _, _cx| {
workspace.register_action(|workspace, _: &ShowCallStats, window, cx| {
workspace.toggle_modal(window, cx, |_window, cx| CallStatsModal::new(cx));
});
})
.detach();
}
pub struct CallStatsModal {
focus_handle: FocusHandle,
_active_call_subscription: Option<Subscription>,
_diagnostics_subscription: Option<Subscription>,
}
impl CallStatsModal {
fn new(cx: &mut Context<Self>) -> Self {
let mut this = Self {
focus_handle: cx.focus_handle(),
_active_call_subscription: None,
_diagnostics_subscription: None,
};
if let Some(active_call) = ActiveCall::try_global(cx) {
this._active_call_subscription =
Some(cx.subscribe(&active_call, Self::handle_call_event));
this.observe_diagnostics(cx);
}
this
}
fn observe_diagnostics(&mut self, cx: &mut Context<Self>) {
let diagnostics = active_room(cx).and_then(|room| room.read(cx).diagnostics().cloned());
if let Some(diagnostics) = diagnostics {
self._diagnostics_subscription = Some(cx.observe(&diagnostics, |_, _, cx| cx.notify()));
} else {
self._diagnostics_subscription = None;
}
}
fn handle_call_event(
&mut self,
_: Entity<ActiveCall>,
event: &room::Event,
cx: &mut Context<Self>,
) {
match event {
room::Event::RoomJoined { .. } => {
self.observe_diagnostics(cx);
}
room::Event::RoomLeft { .. } => {
self._diagnostics_subscription = None;
cx.notify();
}
_ => {}
}
}
fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
cx.emit(DismissEvent);
}
}
fn active_room(cx: &App) -> Option<Entity<Room>> {
ActiveCall::try_global(cx)?.read(cx).room().cloned()
}
fn quality_label(quality: Option<ConnectionQuality>) -> (&'static str, Color) {
match quality {
Some(ConnectionQuality::Excellent) => ("Excellent", Color::Success),
Some(ConnectionQuality::Good) => ("Good", Color::Success),
Some(ConnectionQuality::Poor) => ("Poor", Color::Warning),
Some(ConnectionQuality::Lost) => ("Lost", Color::Error),
None => ("", Color::Muted),
}
}
fn metric_rating(label: &str, value_ms: f64) -> (&'static str, Color) {
match label {
"Latency" => {
if value_ms < 100.0 {
("Normal", Color::Success)
} else if value_ms < 300.0 {
("High", Color::Warning)
} else {
("Poor", Color::Error)
}
}
"Jitter" => {
if value_ms < 30.0 {
("Normal", Color::Success)
} else if value_ms < 75.0 {
("High", Color::Warning)
} else {
("Poor", Color::Error)
}
}
_ => ("Normal", Color::Success),
}
}
fn input_lag_rating(value_ms: f64) -> (&'static str, Color) {
if value_ms < 20.0 {
("Normal", Color::Success)
} else if value_ms < 50.0 {
("High", Color::Warning)
} else {
("Poor", Color::Error)
}
}
fn packet_loss_rating(loss_pct: f64) -> (&'static str, Color) {
if loss_pct < 1.0 {
("Normal", Color::Success)
} else if loss_pct < 5.0 {
("High", Color::Warning)
} else {
("Poor", Color::Error)
}
}
impl EventEmitter<DismissEvent> for CallStatsModal {}
impl ModalView for CallStatsModal {}
impl Focusable for CallStatsModal {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for CallStatsModal {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let room = active_room(cx);
let is_connected = room.is_some();
let stats = room
.and_then(|room| {
let diagnostics = room.read(cx).diagnostics()?;
Some(diagnostics.read(cx).stats().clone())
})
.unwrap_or_default();
let (quality_text, quality_color) = quality_label(stats.connection_quality);
v_flex()
.key_context("CallStatsModal")
.on_action(cx.listener(Self::dismiss))
.track_focus(&self.focus_handle)
.elevation_3(cx)
.w(rems(24.))
.p_4()
.gap_3()
.child(
h_flex()
.justify_between()
.child(Label::new("Call Diagnostics").size(LabelSize::Large))
.child(
Label::new(quality_text)
.size(LabelSize::Large)
.color(quality_color),
),
)
.when(!is_connected, |this| {
this.child(
h_flex()
.justify_center()
.py_4()
.child(Label::new("Not in a call").color(Color::Muted)),
)
})
.when(is_connected, |this| {
this.child(
v_flex()
.gap_1()
.child(
h_flex()
.gap_2()
.child(Label::new("Network").weight(FontWeight::SEMIBOLD)),
)
.child(self.render_metric_row(
"Latency",
"Time for data to travel to the server",
stats.latency_ms,
|v| format!("{:.0}ms", v),
|v| metric_rating("Latency", v),
))
.child(self.render_metric_row(
"Jitter",
"Variance or fluctuation in latency",
stats.jitter_ms,
|v| format!("{:.0}ms", v),
|v| metric_rating("Jitter", v),
))
.child(self.render_metric_row(
"Packet loss",
"Amount of data lost during transfer",
stats.packet_loss_pct,
|v| format!("{:.1}%", v),
|v| packet_loss_rating(v),
))
.child(self.render_metric_row(
"Input lag",
"Delay from audio capture to WebRTC",
stats.input_lag.map(|d| d.as_secs_f64() * 1000.0),
|v| format!("{:.1}ms", v),
|v| input_lag_rating(v),
)),
)
})
}
}
impl CallStatsModal {
fn render_metric_row(
&self,
title: &str,
description: &str,
value: Option<f64>,
format_value: impl Fn(f64) -> String,
rate: impl Fn(f64) -> (&'static str, Color),
) -> impl IntoElement {
let (rating_text, rating_color, value_text) = match value {
Some(v) => {
let (rt, rc) = rate(v);
(rt, rc, format_value(v))
}
None => ("", Color::Muted, "".to_string()),
};
h_flex()
.px_2()
.py_1()
.rounded_md()
.justify_between()
.child(
v_flex()
.child(Label::new(title.to_string()).size(LabelSize::Default))
.child(
Label::new(description.to_string())
.size(LabelSize::Small)
.color(Color::Muted),
),
)
.child(
v_flex()
.items_end()
.child(
Label::new(rating_text)
.size(LabelSize::Default)
.color(rating_color),
)
.child(
Label::new(value_text)
.size(LabelSize::Small)
.color(Color::Muted),
),
)
}
}

View File

@@ -0,0 +1,719 @@
use anyhow::Result;
use call::ActiveCall;
use channel::{Channel, ChannelBuffer, ChannelBufferEvent, ChannelStore};
use client::{
ChannelId, Collaborator, ParticipantIndex,
proto::{self, PeerId},
};
use collections::HashMap;
use editor::{
CollaborationHub, DisplayPoint, Editor, EditorEvent, SelectionEffects,
display_map::ToDisplayPoint, scroll::Autoscroll,
};
use gpui::{
App, ClipboardItem, Context, Entity, EventEmitter, Focusable, Pixels, Point, Render,
Subscription, Task, VisualContext as _, WeakEntity, Window, actions,
};
use project::Project;
use rpc::proto::ChannelVisibility;
use std::{
any::{Any, TypeId},
sync::Arc,
};
use ui::prelude::*;
use util::ResultExt;
use workspace::{CollaboratorId, item::TabContentParams};
use workspace::{
ItemNavHistory, Pane, SaveIntent, Toast, ViewId, Workspace, WorkspaceId,
item::{FollowableItem, Item, ItemEvent},
searchable::SearchableItemHandle,
};
use workspace::{item::Dedup, notifications::NotificationId};
actions!(
collab,
[
/// Copies a link to the current position in the channel buffer.
CopyLink
]
);
pub fn init(cx: &mut App) {
workspace::FollowableViewRegistry::register::<ChannelView>(cx)
}
pub struct ChannelView {
pub editor: Entity<Editor>,
workspace: WeakEntity<Workspace>,
project: Entity<Project>,
channel_store: Entity<ChannelStore>,
channel_buffer: Entity<ChannelBuffer>,
remote_id: Option<ViewId>,
_editor_event_subscription: Subscription,
_reparse_subscription: Option<Subscription>,
}
impl ChannelView {
pub fn open(
channel_id: ChannelId,
link_position: Option<String>,
workspace: Entity<Workspace>,
window: &mut Window,
cx: &mut App,
) -> Task<Result<Entity<Self>>> {
let pane = workspace.read(cx).active_pane().clone();
let channel_view = Self::open_in_pane(
channel_id,
link_position,
pane.clone(),
workspace,
window,
cx,
);
window.spawn(cx, async move |cx| {
let channel_view = channel_view.await?;
pane.update_in(cx, |pane, window, cx| {
telemetry::event!(
"Channel Notes Opened",
channel_id,
room_id = ActiveCall::global(cx)
.read(cx)
.room()
.map(|r| r.read(cx).id())
);
pane.add_item(Box::new(channel_view.clone()), true, true, None, window, cx);
})?;
anyhow::Ok(channel_view)
})
}
pub fn open_in_pane(
channel_id: ChannelId,
link_position: Option<String>,
pane: Entity<Pane>,
workspace: Entity<Workspace>,
window: &mut Window,
cx: &mut App,
) -> Task<Result<Entity<Self>>> {
let channel_view = Self::load(channel_id, workspace, window, cx);
window.spawn(cx, async move |cx| {
let channel_view = channel_view.await?;
pane.update_in(cx, |pane, window, cx| {
let buffer_id = channel_view.read(cx).channel_buffer.read(cx).remote_id(cx);
let existing_view = pane
.items_of_type::<Self>()
.find(|view| view.read(cx).channel_buffer.read(cx).remote_id(cx) == buffer_id);
// If this channel buffer is already open in this pane, just return it.
if let Some(existing_view) = existing_view.clone()
&& existing_view.read(cx).channel_buffer == channel_view.read(cx).channel_buffer
{
if let Some(link_position) = link_position {
existing_view.update(cx, |channel_view, cx| {
channel_view.focus_position_from_link(link_position, true, window, cx)
});
}
return existing_view;
}
// If the pane contained a disconnected view for this channel buffer,
// replace that.
if let Some(existing_item) = existing_view
&& let Some(ix) = pane.index_for_item(&existing_item)
{
pane.close_item_by_id(existing_item.entity_id(), SaveIntent::Skip, window, cx)
.detach();
pane.add_item(
Box::new(channel_view.clone()),
true,
true,
Some(ix),
window,
cx,
);
}
if let Some(link_position) = link_position {
channel_view.update(cx, |channel_view, cx| {
channel_view.focus_position_from_link(link_position, true, window, cx)
});
}
channel_view
})
})
}
pub fn load(
channel_id: ChannelId,
workspace: Entity<Workspace>,
window: &mut Window,
cx: &mut App,
) -> Task<Result<Entity<Self>>> {
let weak_workspace = workspace.downgrade();
let workspace = workspace.read(cx);
let project = workspace.project().to_owned();
let channel_store = ChannelStore::global(cx);
let language_registry = workspace.app_state().languages.clone();
let markdown = language_registry.language_for_name("Markdown");
let channel_buffer =
channel_store.update(cx, |store, cx| store.open_channel_buffer(channel_id, cx));
window.spawn(cx, async move |cx| {
let channel_buffer = channel_buffer.await?;
let markdown = markdown.await.log_err();
channel_buffer.update(cx, |channel_buffer, cx| {
channel_buffer.buffer().update(cx, |buffer, cx| {
buffer.set_language_registry(language_registry);
let Some(markdown) = markdown else {
return;
};
buffer.set_language(Some(markdown), cx);
})
});
cx.new_window_entity(|window, cx| {
let mut this = Self::new(
project,
weak_workspace,
channel_store,
channel_buffer,
window,
cx,
);
this.acknowledge_buffer_version(cx);
this
})
})
}
pub fn new(
project: Entity<Project>,
workspace: WeakEntity<Workspace>,
channel_store: Entity<ChannelStore>,
channel_buffer: Entity<ChannelBuffer>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let buffer = channel_buffer.read(cx).buffer();
let this = cx.entity().downgrade();
let editor = cx.new(|cx| {
let mut editor = Editor::for_buffer(buffer, None, window, cx);
editor.set_collaboration_hub(Box::new(ChannelBufferCollaborationHub(
channel_buffer.clone(),
)));
editor.set_custom_context_menu(move |_, position, window, cx| {
let this = this.clone();
Some(ui::ContextMenu::build(window, cx, move |menu, _, _| {
menu.entry("Copy Link to Section", None, move |window, cx| {
this.update(cx, |this, cx| {
this.copy_link_for_position(position, window, cx)
})
.ok();
})
}))
});
editor.set_show_bookmarks(false, cx);
editor.set_show_breakpoints(false, cx);
editor.set_show_runnables(false, cx);
editor
});
let _editor_event_subscription =
cx.subscribe(&editor, |_, _, e: &EditorEvent, cx| cx.emit(e.clone()));
cx.subscribe_in(&channel_buffer, window, Self::handle_channel_buffer_event)
.detach();
Self {
editor,
workspace,
project,
channel_store,
channel_buffer,
remote_id: None,
_editor_event_subscription,
_reparse_subscription: None,
}
}
fn focus_position_from_link(
&mut self,
position: String,
first_attempt: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
let position = Channel::slug(&position).to_lowercase();
let snapshot = self
.editor
.update(cx, |editor, cx| editor.snapshot(window, cx));
if let Some(outline) = snapshot.buffer_snapshot().outline(None)
&& let Some(item) = outline
.items
.iter()
.find(|item| &Channel::slug(&item.text).to_lowercase() == &position)
{
self.editor.update(cx, |editor, cx| {
editor.change_selections(
SelectionEffects::scroll(Autoscroll::focused()),
window,
cx,
|s| s.replace_cursors_with(|map| vec![item.range.start.to_display_point(map)]),
)
});
return;
}
if !first_attempt {
return;
}
self._reparse_subscription = Some(cx.subscribe_in(
&self.editor,
window,
move |this, _, e: &EditorEvent, window, cx| {
match e {
EditorEvent::Reparsed(_) => {
this.focus_position_from_link(position.clone(), false, window, cx);
this._reparse_subscription.take();
}
EditorEvent::Edited { .. } | EditorEvent::SelectionsChanged { local: true } => {
this._reparse_subscription.take();
}
_ => {}
};
},
));
}
fn copy_link(&mut self, _: &CopyLink, window: &mut Window, cx: &mut Context<Self>) {
let position = self.editor.update(cx, |editor, cx| {
editor
.selections
.newest_display(&editor.display_snapshot(cx))
.start
});
self.copy_link_for_position(position, window, cx)
}
fn copy_link_for_position(
&self,
position: DisplayPoint,
window: &mut Window,
cx: &mut Context<Self>,
) {
let snapshot = self
.editor
.update(cx, |editor, cx| editor.snapshot(window, cx));
let mut closest_heading = None;
if let Some(outline) = snapshot.buffer_snapshot().outline(None) {
for item in outline.items {
if item.range.start.to_display_point(&snapshot) > position {
break;
}
closest_heading = Some(item);
}
}
let Some(channel) = self.channel(cx) else {
return;
};
let link = channel.notes_link(closest_heading.map(|heading| heading.text), cx);
cx.write_to_clipboard(ClipboardItem::new_string(link));
self.workspace
.update(cx, |workspace, cx| {
struct CopyLinkForPositionToast;
workspace.show_toast(
Toast::new(
NotificationId::unique::<CopyLinkForPositionToast>(),
"Link copied to clipboard",
),
cx,
);
})
.ok();
}
pub fn channel(&self, cx: &App) -> Option<Arc<Channel>> {
self.channel_buffer.read(cx).channel(cx)
}
fn handle_channel_buffer_event(
&mut self,
_: &Entity<ChannelBuffer>,
event: &ChannelBufferEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
match event {
ChannelBufferEvent::Disconnected => self.editor.update(cx, |editor, cx| {
editor.set_read_only(true);
cx.notify();
}),
ChannelBufferEvent::Connected => self.editor.update(cx, |editor, cx| {
editor.set_read_only(false);
cx.notify();
}),
ChannelBufferEvent::ChannelChanged => {
self.editor.update(cx, |_, cx| {
cx.emit(editor::EditorEvent::TitleChanged);
cx.notify()
});
}
ChannelBufferEvent::BufferEdited => {
if self.editor.read(cx).is_focused(window) {
self.acknowledge_buffer_version(cx);
} else {
self.channel_store.update(cx, |store, cx| {
let channel_buffer = self.channel_buffer.read(cx);
store.update_latest_notes_version(
channel_buffer.channel_id,
channel_buffer.epoch(),
&channel_buffer.buffer().read(cx).version(),
cx,
)
});
}
}
ChannelBufferEvent::CollaboratorsChanged => {}
}
}
fn acknowledge_buffer_version(&mut self, cx: &mut Context<ChannelView>) {
self.channel_store.update(cx, |store, cx| {
let channel_buffer = self.channel_buffer.read(cx);
store.acknowledge_notes_version(
channel_buffer.channel_id,
channel_buffer.epoch(),
&channel_buffer.buffer().read(cx).version(),
cx,
)
});
self.channel_buffer.update(cx, |buffer, cx| {
buffer.acknowledge_buffer_version(cx);
});
}
fn get_channel(&self, cx: &App) -> (SharedString, Option<SharedString>) {
if let Some(channel) = self.channel(cx) {
let status = match (
self.channel_buffer.read(cx).buffer().read(cx).read_only(),
self.channel_buffer.read(cx).is_connected(),
) {
(false, true) => None,
(true, true) => Some("read-only"),
(_, false) => Some("disconnected"),
};
(channel.name.clone(), status.map(Into::into))
} else {
("<unknown>".into(), Some("disconnected".into()))
}
}
}
impl EventEmitter<EditorEvent> for ChannelView {}
impl Render for ChannelView {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.size_full()
.on_action(cx.listener(Self::copy_link))
.child(self.editor.clone())
}
}
impl Focusable for ChannelView {
fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
self.editor.read(cx).focus_handle(cx)
}
}
impl Item for ChannelView {
type Event = EditorEvent;
fn act_as_type<'a>(
&'a self,
type_id: TypeId,
self_handle: &'a Entity<Self>,
_: &'a App,
) -> Option<gpui::AnyEntity> {
if type_id == TypeId::of::<Self>() {
Some(self_handle.clone().into())
} else if type_id == TypeId::of::<Editor>() {
Some(self.editor.clone().into())
} else {
None
}
}
fn tab_icon(&self, _: &Window, cx: &App) -> Option<Icon> {
let channel = self.channel(cx)?;
let icon = match channel.visibility {
ChannelVisibility::Public => IconName::Public,
ChannelVisibility::Members => IconName::Hash,
};
Some(Icon::new(icon))
}
fn tab_content_text(&self, _detail: usize, cx: &App) -> SharedString {
let (name, status) = self.get_channel(cx);
if let Some(status) = status {
format!("{name} - {status}").into()
} else {
name
}
}
fn tab_content(&self, params: TabContentParams, _: &Window, cx: &App) -> gpui::AnyElement {
let (name, status) = self.get_channel(cx);
h_flex()
.gap_2()
.child(
Label::new(name)
.color(params.text_color())
.when(params.preview, |this| this.italic()),
)
.when_some(status, |element, status| {
element.child(
Label::new(status)
.size(LabelSize::XSmall)
.color(Color::Muted),
)
})
.into_any_element()
}
fn telemetry_event_text(&self) -> Option<&'static str> {
None
}
fn can_split(&self) -> bool {
true
}
fn clone_on_split(
&self,
_: Option<WorkspaceId>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Option<Entity<Self>>> {
Task::ready(Some(cx.new(|cx| {
Self::new(
self.project.clone(),
self.workspace.clone(),
self.channel_store.clone(),
self.channel_buffer.clone(),
window,
cx,
)
})))
}
fn navigate(
&mut self,
data: Arc<dyn Any + Send>,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
self.editor
.update(cx, |editor, cx| editor.navigate(data, window, cx))
}
fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.editor
.update(cx, |item, cx| item.deactivated(window, cx))
}
fn set_nav_history(
&mut self,
history: ItemNavHistory,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.editor.update(cx, |editor, cx| {
Item::set_nav_history(editor, history, window, cx)
})
}
fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
Some(Box::new(self.editor.clone()))
}
fn show_toolbar(&self) -> bool {
true
}
fn pixel_position_of_cursor(&self, cx: &App) -> Option<Point<Pixels>> {
self.editor.read(cx).pixel_position_of_cursor(cx)
}
fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
Editor::to_item_events(event, f)
}
}
impl FollowableItem for ChannelView {
fn remote_id(&self) -> Option<workspace::ViewId> {
self.remote_id
}
fn to_state_proto(&self, window: &mut Window, cx: &mut App) -> Option<proto::view::Variant> {
let (is_connected, channel_id) = {
let channel_buffer = self.channel_buffer.read(cx);
(channel_buffer.is_connected(), channel_buffer.channel_id.0)
};
if !is_connected {
return None;
}
let editor_proto = self
.editor
.update(cx, |editor, cx| editor.to_state_proto(window, cx));
Some(proto::view::Variant::ChannelView(
proto::view::ChannelView {
channel_id,
editor: if let Some(proto::view::Variant::Editor(proto)) = editor_proto {
Some(proto)
} else {
None
},
},
))
}
fn from_state_proto(
workspace: Entity<workspace::Workspace>,
remote_id: workspace::ViewId,
state: &mut Option<proto::view::Variant>,
window: &mut Window,
cx: &mut App,
) -> Option<gpui::Task<anyhow::Result<Entity<Self>>>> {
let Some(proto::view::Variant::ChannelView(_)) = state else {
return None;
};
let Some(proto::view::Variant::ChannelView(state)) = state.take() else {
unreachable!()
};
let open = ChannelView::load(ChannelId(state.channel_id), workspace, window, cx);
Some(window.spawn(cx, async move |cx| {
let this = open.await?;
let task = this.update_in(cx, |this, window, cx| {
this.remote_id = Some(remote_id);
if let Some(state) = state.editor {
Some(this.editor.update(cx, |editor, cx| {
editor.apply_update_proto(
&this.project,
proto::update_view::Variant::Editor(proto::update_view::Editor {
selections: state.selections,
pending_selection: state.pending_selection,
scroll_top_anchor: state.scroll_top_anchor,
scroll_x: state.scroll_x,
scroll_y: state.scroll_y,
..Default::default()
}),
window,
cx,
)
}))
} else {
None
}
})?;
if let Some(task) = task {
task.await?;
}
Ok(this)
}))
}
fn add_event_to_update_proto(
&self,
event: &EditorEvent,
update: &mut Option<proto::update_view::Variant>,
window: &mut Window,
cx: &mut App,
) -> bool {
self.editor.update(cx, |editor, cx| {
editor.add_event_to_update_proto(event, update, window, cx)
})
}
fn apply_update_proto(
&mut self,
project: &Entity<Project>,
message: proto::update_view::Variant,
window: &mut Window,
cx: &mut Context<Self>,
) -> gpui::Task<anyhow::Result<()>> {
self.editor.update(cx, |editor, cx| {
editor.apply_update_proto(project, message, window, cx)
})
}
fn set_leader_id(
&mut self,
leader_id: Option<CollaboratorId>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.editor
.update(cx, |editor, cx| editor.set_leader_id(leader_id, window, cx))
}
fn is_project_item(&self, _window: &Window, _cx: &App) -> bool {
false
}
fn to_follow_event(event: &Self::Event) -> Option<workspace::item::FollowEvent> {
Editor::to_follow_event(event)
}
fn dedup(&self, existing: &Self, _: &Window, cx: &App) -> Option<Dedup> {
let existing = existing.channel_buffer.read(cx);
if self.channel_buffer.read(cx).channel_id == existing.channel_id {
if existing.is_connected() {
Some(Dedup::KeepExisting)
} else {
Some(Dedup::ReplaceExisting)
}
} else {
None
}
}
}
struct ChannelBufferCollaborationHub(Entity<ChannelBuffer>);
impl CollaborationHub for ChannelBufferCollaborationHub {
fn collaborators<'a>(&self, cx: &'a App) -> &'a HashMap<PeerId, Collaborator> {
self.0.read(cx).collaborators()
}
fn user_participant_indices<'a>(&self, cx: &'a App) -> &'a HashMap<u64, ParticipantIndex> {
self.0.read(cx).user_store().read(cx).participant_indices()
}
fn user_names(&self, cx: &App) -> 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)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,682 @@
use channel::{ChannelMembership, ChannelStore};
use client::{
ChannelId, LegacyUserId, User, UserStore,
proto::{self, ChannelRole, ChannelVisibility},
};
use fuzzy::{StringMatchCandidate, match_strings};
use gpui::{
App, ClipboardItem, Context, DismissEvent, Entity, EventEmitter, Focusable, ParentElement,
Render, Styled, Subscription, Task, TaskExt, WeakEntity, Window, actions, anchored, deferred,
div,
};
use picker::{Picker, PickerDelegate};
use std::sync::Arc;
use ui::{Avatar, Checkbox, ContextMenu, ListItem, ListItemSpacing, prelude::*};
use util::TryFutureExt;
use workspace::{ModalView, notifications::DetachAndPromptErr};
actions!(
channel_modal,
[
/// Selects the next control in the channel modal.
SelectNextControl,
/// Toggles between invite members and manage members mode.
ToggleMode,
/// Toggles admin status for the selected member.
ToggleMemberAdmin,
/// Removes the selected member from the channel.
RemoveMember
]
);
pub struct ChannelModal {
picker: Entity<Picker<ChannelModalDelegate>>,
channel_store: Entity<ChannelStore>,
channel_id: ChannelId,
}
impl ChannelModal {
pub fn new(
user_store: Entity<UserStore>,
channel_store: Entity<ChannelStore>,
channel_id: ChannelId,
mode: Mode,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
cx.observe(&channel_store, |_, _, cx| cx.notify()).detach();
let channel_modal = cx.entity().downgrade();
let picker = cx.new(|cx| {
Picker::uniform_list(
ChannelModalDelegate {
channel_modal,
matching_users: Vec::new(),
matching_member_indices: Vec::new(),
selected_index: 0,
user_store: user_store.clone(),
channel_store: channel_store.clone(),
channel_id,
match_candidates: Vec::new(),
context_menu: None,
members: Vec::new(),
has_all_members: false,
mode,
},
window,
cx,
)
.modal(false)
});
Self {
picker,
channel_store,
channel_id,
}
}
fn toggle_mode(&mut self, _: &ToggleMode, window: &mut Window, cx: &mut Context<Self>) {
let mode = match self.picker.read(cx).delegate.mode {
Mode::ManageMembers => Mode::InviteMembers,
Mode::InviteMembers => Mode::ManageMembers,
};
self.set_mode(mode, window, cx);
}
fn set_mode(&mut self, mode: Mode, window: &mut Window, cx: &mut Context<Self>) {
self.picker.update(cx, |picker, cx| {
let delegate = &mut picker.delegate;
delegate.mode = mode;
delegate.selected_index = 0;
picker.set_query("", window, cx);
picker.update_matches(picker.query(cx), window, cx);
cx.notify()
});
cx.notify()
}
fn set_channel_visibility(
&mut self,
selection: &ToggleState,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.channel_store.update(cx, |channel_store, cx| {
channel_store
.set_channel_visibility(
self.channel_id,
match selection {
ToggleState::Unselected => ChannelVisibility::Members,
ToggleState::Selected => ChannelVisibility::Public,
ToggleState::Indeterminate => return,
},
cx,
)
.detach_and_log_err(cx)
});
}
fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
cx.emit(DismissEvent);
}
}
impl EventEmitter<DismissEvent> for ChannelModal {}
impl ModalView for ChannelModal {}
impl Focusable for ChannelModal {
fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
self.picker.focus_handle(cx)
}
}
impl Render for ChannelModal {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let channel_store = self.channel_store.read(cx);
let Some(channel) = channel_store.channel_for_id(self.channel_id) else {
return div();
};
let channel_name = channel.name.clone();
let channel_id = channel.id;
let visibility = channel.visibility;
let mode = self.picker.read(cx).delegate.mode;
v_flex()
.key_context("ChannelModal")
.on_action(cx.listener(Self::toggle_mode))
.on_action(cx.listener(Self::dismiss))
.elevation_3(cx)
.w(rems(34.))
.child(
v_flex()
.px_2()
.py_1()
.gap_2()
.child(
h_flex()
.w_px()
.flex_1()
.gap_1()
.child(Icon::new(IconName::Hash).size(IconSize::Medium))
.child(Label::new(channel_name)),
)
.child(
h_flex()
.w_full()
.h(rems_from_px(22.))
.justify_between()
.line_height(rems(1.25))
.child(
Checkbox::new(
"is-public",
if visibility == ChannelVisibility::Public {
ui::ToggleState::Selected
} else {
ui::ToggleState::Unselected
},
)
.label("Public")
.on_click(cx.listener(Self::set_channel_visibility)),
)
.children(
Some(
Button::new("copy-link", "Copy Link")
.label_size(LabelSize::Small)
.on_click(cx.listener(move |this, _, _, cx| {
if let Some(channel) = this
.channel_store
.read(cx)
.channel_for_id(channel_id)
{
let item =
ClipboardItem::new_string(channel.link(cx));
cx.write_to_clipboard(item);
}
})),
)
.filter(|_| visibility == ChannelVisibility::Public),
),
)
.child(
h_flex()
.child(
div()
.id("manage-members")
.px_2()
.py_1()
.cursor_pointer()
.border_b_2()
.when(mode == Mode::ManageMembers, |this| {
this.border_color(cx.theme().colors().border)
})
.child(Label::new("Manage Members"))
.on_click(cx.listener(|this, _, window, cx| {
this.set_mode(Mode::ManageMembers, window, cx);
})),
)
.child(
div()
.id("invite-members")
.px_2()
.py_1()
.cursor_pointer()
.border_b_2()
.when(mode == Mode::InviteMembers, |this| {
this.border_color(cx.theme().colors().border)
})
.child(Label::new("Invite Members"))
.on_click(cx.listener(|this, _, window, cx| {
this.set_mode(Mode::InviteMembers, window, cx);
})),
),
),
)
.child(self.picker.clone())
}
}
#[derive(Copy, Clone, PartialEq)]
pub enum Mode {
ManageMembers,
InviteMembers,
}
pub struct ChannelModalDelegate {
channel_modal: WeakEntity<ChannelModal>,
matching_users: Vec<Arc<User>>,
matching_member_indices: Vec<usize>,
user_store: Entity<UserStore>,
channel_store: Entity<ChannelStore>,
channel_id: ChannelId,
selected_index: usize,
mode: Mode,
match_candidates: Vec<StringMatchCandidate>,
members: Vec<ChannelMembership>,
has_all_members: bool,
context_menu: Option<(Entity<ContextMenu>, Subscription)>,
}
impl PickerDelegate for ChannelModalDelegate {
type ListItem = ListItem;
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
"Search collaborator by username...".into()
}
fn match_count(&self) -> usize {
match self.mode {
Mode::ManageMembers => self.matching_member_indices.len(),
Mode::InviteMembers => self.matching_users.len(),
}
}
fn selected_index(&self) -> usize {
self.selected_index
}
fn set_selected_index(
&mut self,
ix: usize,
_window: &mut Window,
_: &mut Context<Picker<Self>>,
) {
self.selected_index = ix;
}
fn update_matches(
&mut self,
query: String,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Task<()> {
match self.mode {
Mode::ManageMembers => {
if self.has_all_members {
self.match_candidates.clear();
self.match_candidates
.extend(self.members.iter().enumerate().map(|(id, member)| {
StringMatchCandidate::new(id, &member.user.github_login)
}));
let matches = cx.foreground_executor().block_on(match_strings(
&self.match_candidates,
&query,
true,
true,
usize::MAX,
&Default::default(),
cx.background_executor().clone(),
));
cx.spawn_in(window, async move |picker, cx| {
picker
.update(cx, |picker, cx| {
let delegate = &mut picker.delegate;
delegate.matching_member_indices.clear();
delegate
.matching_member_indices
.extend(matches.into_iter().map(|m| m.candidate_id));
cx.notify();
})
.ok();
})
} else {
let search_members = self.channel_store.update(cx, |store, cx| {
store.fuzzy_search_members(self.channel_id, query.clone(), 100, cx)
});
cx.spawn_in(window, async move |picker, cx| {
async {
let members = search_members.await?;
picker.update(cx, |picker, cx| {
picker.delegate.has_all_members =
query.is_empty() && members.len() < 100;
picker.delegate.matching_member_indices =
(0..members.len()).collect();
picker.delegate.members = members;
cx.notify();
})?;
anyhow::Ok(())
}
.log_err()
.await;
})
}
}
Mode::InviteMembers => {
let search_users = self
.user_store
.update(cx, |store, cx| store.fuzzy_search_users(query, cx));
cx.spawn_in(window, async move |picker, cx| {
async {
let users = search_users.await?;
picker.update(cx, |picker, cx| {
picker.delegate.matching_users = users;
cx.notify();
})?;
anyhow::Ok(())
}
.log_err()
.await;
})
}
}
}
fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
if let Some(selected_user) = self.user_at_index(self.selected_index) {
if Some(selected_user.legacy_id)
== self
.user_store
.read(cx)
.current_user()
.map(|user| user.legacy_id)
{
return;
}
match self.mode {
Mode::ManageMembers => self.show_context_menu(self.selected_index, window, cx),
Mode::InviteMembers => match self.member_status(selected_user.legacy_id, cx) {
Some(proto::channel_member::Kind::Invitee) => {
self.remove_member(selected_user.legacy_id, window, cx);
}
Some(proto::channel_member::Kind::Member) => {}
None => self.invite_member(selected_user, window, cx),
},
}
}
}
fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
if self.context_menu.is_none() {
self.channel_modal
.update(cx, |_, cx| {
cx.emit(DismissEvent);
})
.ok();
}
}
fn render_match(
&self,
ix: usize,
selected: bool,
_: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
let user = self.user_at_index(ix)?;
let membership = self.member_at_index(ix);
let request_status = self.member_status(user.legacy_id, cx);
let is_me = self
.user_store
.read(cx)
.current_user()
.map(|user| user.legacy_id)
== Some(user.legacy_id);
Some(
ListItem::new(ix)
.inset(true)
.spacing(ListItemSpacing::Sparse)
.toggle_state(selected)
.start_slot(Avatar::new(user.avatar_uri.clone()))
.child(Label::new(user.github_login.clone()))
.end_slot(h_flex().gap_2().map(|slot| {
match self.mode {
Mode::ManageMembers => slot
.children(
if request_status == Some(proto::channel_member::Kind::Invitee) {
Some(Label::new("Invited"))
} else {
None
},
)
.children(match membership.map(|m| m.role) {
Some(ChannelRole::Admin) => Some(Label::new("Admin")),
Some(ChannelRole::Guest) => Some(Label::new("Guest")),
_ => None,
})
.when(!is_me, |el| {
el.child(IconButton::new("ellipsis", IconName::Ellipsis))
})
.when(is_me, |el| el.child(Label::new("You").color(Color::Muted)))
.children(
if let (Some((menu, _)), true) = (&self.context_menu, selected) {
Some(
deferred(
anchored()
.anchor(gpui::Anchor::TopRight)
.child(menu.clone()),
)
.with_priority(1),
)
} else {
None
},
),
Mode::InviteMembers => match request_status {
Some(proto::channel_member::Kind::Invitee) => {
slot.children(Some(Label::new("Invited")))
}
Some(proto::channel_member::Kind::Member) => {
slot.children(Some(Label::new("Member")))
}
_ => slot,
},
}
})),
)
}
}
impl ChannelModalDelegate {
fn member_status(
&self,
user_id: LegacyUserId,
cx: &App,
) -> Option<proto::channel_member::Kind> {
self.members
.iter()
.find_map(|membership| {
(membership.user.legacy_id == user_id).then_some(membership.kind)
})
.or_else(|| {
self.channel_store
.read(cx)
.has_pending_channel_invite(self.channel_id, user_id)
.then_some(proto::channel_member::Kind::Invitee)
})
}
fn member_at_index(&self, ix: usize) -> Option<&ChannelMembership> {
self.matching_member_indices
.get(ix)
.and_then(|ix| self.members.get(*ix))
}
fn user_at_index(&self, ix: usize) -> Option<Arc<User>> {
match self.mode {
Mode::ManageMembers => self.matching_member_indices.get(ix).and_then(|ix| {
let channel_membership = self.members.get(*ix)?;
Some(channel_membership.user.clone())
}),
Mode::InviteMembers => self.matching_users.get(ix).cloned(),
}
}
fn set_user_role(
&mut self,
user_id: LegacyUserId,
new_role: ChannelRole,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<()> {
let update = self.channel_store.update(cx, |store, cx| {
store.set_member_role(self.channel_id, user_id, new_role, cx)
});
cx.spawn_in(window, async move |picker, cx| {
update.await?;
picker.update_in(cx, |picker, window, cx| {
let this = &mut picker.delegate;
if let Some(member) = this
.members
.iter_mut()
.find(|m| m.user.legacy_id == user_id)
{
member.role = new_role;
}
cx.focus_self(window);
cx.notify();
})
})
.detach_and_prompt_err("Failed to update role", window, cx, |_, _, _| None);
Some(())
}
fn remove_member(
&mut self,
user_id: LegacyUserId,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<()> {
let update = self.channel_store.update(cx, |store, cx| {
store.remove_member(self.channel_id, user_id, cx)
});
cx.spawn_in(window, async move |picker, cx| {
update.await?;
picker.update_in(cx, |picker, window, cx| {
let this = &mut picker.delegate;
if let Some(ix) = this
.members
.iter_mut()
.position(|m| m.user.legacy_id == user_id)
{
this.members.remove(ix);
this.matching_member_indices.retain_mut(|member_ix| {
if *member_ix == ix {
return false;
} else if *member_ix > ix {
*member_ix -= 1;
}
true
})
}
this.selected_index = this
.selected_index
.min(this.matching_member_indices.len().saturating_sub(1));
picker.focus(window, cx);
cx.notify();
})
})
.detach_and_prompt_err("Failed to remove member", window, cx, |_, _, _| None);
Some(())
}
fn invite_member(
&mut self,
user: Arc<User>,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) {
let invite_member = self.channel_store.update(cx, |store, cx| {
store.invite_member(self.channel_id, user.legacy_id, ChannelRole::Member, cx)
});
cx.spawn_in(window, async move |this, cx| {
invite_member.await?;
this.update(cx, |this, cx| {
let new_member = ChannelMembership {
user,
kind: proto::channel_member::Kind::Invitee,
role: ChannelRole::Member,
};
let members = &mut this.delegate.members;
match members.binary_search_by_key(&new_member.sort_key(), |k| k.sort_key()) {
Ok(ix) | Err(ix) => members.insert(ix, new_member),
}
cx.notify();
})
})
.detach_and_prompt_err("Failed to invite member", window, cx, |_, _, _| None);
}
fn show_context_menu(
&mut self,
ix: usize,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) {
let Some(membership) = self.member_at_index(ix) else {
return;
};
let user_id = membership.user.legacy_id;
let picker = cx.entity();
let context_menu = ContextMenu::build(window, cx, |mut menu, _window, _cx| {
let role = membership.role;
if role == ChannelRole::Admin || role == ChannelRole::Member {
let picker = picker.clone();
menu = menu.entry("Demote to Guest", None, move |window, cx| {
picker.update(cx, |picker, cx| {
picker
.delegate
.set_user_role(user_id, ChannelRole::Guest, window, cx);
})
});
}
if role == ChannelRole::Admin || role == ChannelRole::Guest {
let picker = picker.clone();
let label = if role == ChannelRole::Guest {
"Promote to Member"
} else {
"Demote to Member"
};
menu = menu.entry(label, None, move |window, cx| {
picker.update(cx, |picker, cx| {
picker
.delegate
.set_user_role(user_id, ChannelRole::Member, window, cx);
})
});
}
if role == ChannelRole::Member || role == ChannelRole::Guest {
let picker = picker.clone();
menu = menu.entry("Promote to Admin", None, move |window, cx| {
picker.update(cx, |picker, cx| {
picker
.delegate
.set_user_role(user_id, ChannelRole::Admin, window, cx);
})
});
};
menu = menu.separator();
menu = menu.entry("Remove from Channel", None, {
let picker = picker.clone();
move |window, cx| {
picker.update(cx, |picker, cx| {
picker.delegate.remove_member(user_id, window, cx);
})
}
});
menu
});
window.focus(&context_menu.focus_handle(cx), cx);
let subscription = cx.subscribe_in(
&context_menu,
window,
|picker, _, _: &DismissEvent, window, cx| {
picker.delegate.context_menu = None;
picker.focus(window, cx);
cx.notify();
},
);
self.context_menu = Some((context_menu, subscription));
}
}

View File

@@ -0,0 +1,171 @@
use client::{ContactRequestStatus, User, UserStore};
use gpui::{
App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, ParentElement as _,
Render, Styled, Task, WeakEntity, Window,
};
use picker::{Picker, PickerDelegate};
use std::sync::Arc;
use ui::{Avatar, ListItem, ListItemSpacing, prelude::*};
use util::{ResultExt as _, TryFutureExt};
use workspace::ModalView;
pub struct ContactFinder {
picker: Entity<Picker<ContactFinderDelegate>>,
}
impl ContactFinder {
pub fn new(user_store: Entity<UserStore>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let delegate = ContactFinderDelegate {
parent: cx.entity().downgrade(),
user_store,
potential_contacts: Arc::from([]),
selected_index: 0,
};
let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false));
Self { picker }
}
pub fn set_query(&mut self, query: String, window: &mut Window, cx: &mut Context<Self>) {
self.picker.update(cx, |picker, cx| {
picker.set_query(&query, window, cx);
});
}
}
impl Render for ContactFinder {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.elevation_3(cx)
.child(
v_flex()
.px_2()
.py_1()
.bg(cx.theme().colors().element_background)
// HACK: Prevent the background color from overflowing the parent container.
.rounded_t(px(8.))
.child(Label::new("Contacts"))
.child(h_flex().child(Label::new("Invite new contacts"))),
)
.child(self.picker.clone())
.w(rems(34.))
}
}
pub struct ContactFinderDelegate {
parent: WeakEntity<ContactFinder>,
potential_contacts: Arc<[Arc<User>]>,
user_store: Entity<UserStore>,
selected_index: usize,
}
impl EventEmitter<DismissEvent> for ContactFinder {}
impl ModalView for ContactFinder {}
impl Focusable for ContactFinder {
fn focus_handle(&self, cx: &App) -> FocusHandle {
self.picker.focus_handle(cx)
}
}
impl PickerDelegate for ContactFinderDelegate {
type ListItem = ListItem;
fn match_count(&self) -> usize {
self.potential_contacts.len()
}
fn selected_index(&self) -> usize {
self.selected_index
}
fn set_selected_index(
&mut self,
ix: usize,
_window: &mut Window,
_: &mut Context<Picker<Self>>,
) {
self.selected_index = ix;
}
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
"Search collaborator by username...".into()
}
fn update_matches(
&mut self,
query: String,
window: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Task<()> {
let search_users = self
.user_store
.update(cx, |store, cx| store.fuzzy_search_users(query, cx));
cx.spawn_in(window, async move |picker, cx| {
async {
let potential_contacts = search_users.await?;
picker.update(cx, |picker, cx| {
picker.delegate.potential_contacts = potential_contacts.into();
cx.notify();
})?;
anyhow::Ok(())
}
.log_err()
.await;
})
}
fn confirm(&mut self, _: bool, _: &mut Window, cx: &mut Context<Picker<Self>>) {
if let Some(user) = self.potential_contacts.get(self.selected_index) {
let user_store = self.user_store.read(cx);
match user_store.contact_request_status(user) {
ContactRequestStatus::None | ContactRequestStatus::RequestReceived => {
self.user_store
.update(cx, |store, cx| store.request_contact(user.legacy_id, cx))
.detach();
}
ContactRequestStatus::RequestSent => {
self.user_store
.update(cx, |store, cx| store.remove_contact(user.legacy_id, cx))
.detach();
}
_ => {}
}
}
}
fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
self.parent
.update(cx, |_, cx| cx.emit(DismissEvent))
.log_err();
}
fn render_match(
&self,
ix: usize,
selected: bool,
_: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
let user = &self.potential_contacts.get(ix)?;
let request_status = self.user_store.read(cx).contact_request_status(user);
let icon_path = match request_status {
ContactRequestStatus::None | ContactRequestStatus::RequestReceived => {
Some("icons/check.svg")
}
ContactRequestStatus::RequestSent => Some("icons/x.svg"),
ContactRequestStatus::RequestAccepted => None,
};
Some(
ListItem::new(ix)
.inset(true)
.spacing(ListItemSpacing::Sparse)
.toggle_state(selected)
.start_slot(Avatar::new(user.avatar_uri.clone()))
.child(Label::new(user.github_login.clone()))
.end_slot::<Icon>(icon_path.map(Icon::from_path)),
)
}
}

View File

@@ -0,0 +1,62 @@
mod call_stats_modal;
pub mod channel_view;
pub mod collab_panel;
pub mod notifications;
mod panel_settings;
use std::{rc::Rc, sync::Arc};
pub use collab_panel::CollabPanel;
use gpui::{
App, Pixels, PlatformDisplay, Size, WindowBackgroundAppearance, WindowBounds,
WindowDecorations, WindowKind, WindowOptions, point,
};
pub use panel_settings::CollaborationPanelSettings;
use release_channel::ReleaseChannel;
use ui::px;
use workspace::AppState;
// Another comment, nice.
pub fn init(app_state: &Arc<AppState>, cx: &mut App) {
call_stats_modal::init(cx);
channel_view::init(cx);
collab_panel::init(cx);
notifications::init(app_state, cx);
title_bar::init(cx);
}
fn notification_window_options(
screen: Rc<dyn PlatformDisplay>,
size: Size<Pixels>,
cx: &App,
) -> WindowOptions {
let notification_margin_width = px(16.);
let notification_margin_height = px(-48.);
let bounds = gpui::Bounds::<Pixels> {
origin: screen.bounds().top_right()
- point(
size.width + notification_margin_width,
notification_margin_height,
),
size,
};
let app_id = ReleaseChannel::global(cx).app_id();
WindowOptions {
window_bounds: Some(WindowBounds::Windowed(bounds)),
titlebar: None,
focus: false,
show: true,
kind: WindowKind::PopUp,
is_movable: false,
display_id: Some(screen.id()),
window_background: WindowBackgroundAppearance::Transparent,
app_id: Some(app_id.to_owned()),
window_min_size: None,
window_decorations: Some(WindowDecorations::Client),
tabbing_identifier: None,
..Default::default()
}
}

View File

@@ -0,0 +1,11 @@
pub mod incoming_call_notification;
pub mod project_shared_notification;
use gpui::App;
use std::sync::Arc;
use workspace::AppState;
pub fn init(app_state: &Arc<AppState>, cx: &mut App) {
incoming_call_notification::init(app_state, cx);
project_shared_notification::init(app_state, cx);
}

View File

@@ -0,0 +1,134 @@
use crate::notification_window_options;
use call::{ActiveCall, IncomingCall};
use futures::StreamExt;
use gpui::{App, TaskExt, WindowHandle, prelude::*};
use std::sync::{Arc, Weak};
use ui::{CollabNotification, prelude::*};
use util::ResultExt;
use workspace::AppState;
pub fn init(app_state: &Arc<AppState>, cx: &mut App) {
let app_state = Arc::downgrade(app_state);
let mut incoming_call = ActiveCall::global(cx).read(cx).incoming();
cx.spawn(async move |cx| {
let mut notification_windows: Vec<WindowHandle<IncomingCallNotification>> = Vec::new();
while let Some(incoming_call) = incoming_call.next().await {
for window in notification_windows.drain(..) {
window
.update(cx, |_, window, _| {
window.remove_window();
})
.log_err();
}
if let Some(incoming_call) = incoming_call {
let unique_screens = cx.update(|cx| cx.displays());
let window_size = gpui::Size {
width: px(400.),
height: px(72.),
};
for screen in unique_screens {
let options =
cx.update(|cx| notification_window_options(screen, window_size, cx));
if let Ok(window) = cx.open_window(options, |_, cx| {
cx.new(|_| {
IncomingCallNotification::new(incoming_call.clone(), app_state.clone())
})
}) {
notification_windows.push(window);
}
}
}
}
for window in notification_windows.drain(..) {
window
.update(cx, |_, window, _| {
window.remove_window();
})
.log_err();
}
})
.detach();
}
struct IncomingCallNotificationState {
call: IncomingCall,
app_state: Weak<AppState>,
}
pub struct IncomingCallNotification {
state: Arc<IncomingCallNotificationState>,
}
impl IncomingCallNotificationState {
pub fn new(call: IncomingCall, app_state: Weak<AppState>) -> Self {
Self { call, app_state }
}
fn respond(&self, accept: bool, cx: &mut App) {
let active_call = ActiveCall::global(cx);
if accept {
let join = active_call.update(cx, |active_call, cx| active_call.accept_incoming(cx));
let caller_user_id = self.call.calling_user.legacy_id;
let initial_project_id = self.call.initial_project.as_ref().map(|project| project.id);
let app_state = self.app_state.clone();
let cx: &mut App = cx;
cx.spawn(async move |cx| {
join.await?;
if let Some(project_id) = initial_project_id {
cx.update(|cx| {
if let Some(app_state) = app_state.upgrade() {
workspace::join_in_room_project(
project_id,
caller_user_id,
app_state,
cx,
)
.detach_and_log_err(cx);
}
});
}
anyhow::Ok(())
})
.detach_and_log_err(cx);
} else {
active_call.update(cx, |active_call, cx| {
active_call.decline_incoming(cx).log_err();
});
}
}
}
impl IncomingCallNotification {
pub fn new(call: IncomingCall, app_state: Weak<AppState>) -> Self {
Self {
state: Arc::new(IncomingCallNotificationState::new(call, app_state)),
}
}
}
impl Render for IncomingCallNotification {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let ui_font = theme_settings::setup_ui_font(window, cx);
div().size_full().font(ui_font).child(
CollabNotification::new(
self.state.call.calling_user.avatar_uri.clone(),
Button::new("accept", "Accept").on_click({
let state = self.state.clone();
move |_, _, cx| state.respond(true, cx)
}),
Button::new("decline", "Decline").on_click({
let state = self.state.clone();
move |_, _, cx| state.respond(false, cx)
}),
)
.child(Label::new(format!(
"{} is sharing a project in Zed",
self.state.call.calling_user.github_login
))),
)
}
}

View File

@@ -0,0 +1,151 @@
use crate::notification_window_options;
use call::{ActiveCall, room};
use client::User;
use collections::HashMap;
use gpui::{App, Size, TaskExt};
use std::sync::{Arc, Weak};
use ui::{CollabNotification, prelude::*};
use util::ResultExt;
use workspace::AppState;
pub fn init(app_state: &Arc<AppState>, cx: &mut App) {
let app_state = Arc::downgrade(app_state);
let active_call = ActiveCall::global(cx);
let mut notification_windows = HashMap::default();
cx.subscribe(&active_call, move |_, event, cx| match event {
room::Event::RemoteProjectShared {
owner,
project_id,
worktree_root_names,
} => {
let window_size = Size {
width: px(400.),
height: px(72.),
};
for screen in cx.displays() {
let options = notification_window_options(screen, window_size, cx);
let Some(window) = cx
.open_window(options, |_, cx| {
cx.new(|_| {
ProjectSharedNotification::new(
owner.clone(),
*project_id,
worktree_root_names.clone(),
app_state.clone(),
)
})
})
.log_err()
else {
continue;
};
notification_windows
.entry(*project_id)
.or_insert(Vec::new())
.push(window);
}
}
room::Event::RemoteProjectUnshared { project_id }
| room::Event::RemoteProjectJoined { project_id }
| room::Event::RemoteProjectInvitationDiscarded { project_id } => {
if let Some(windows) = notification_windows.remove(project_id) {
for window in windows {
window
.update(cx, |_, window, _| {
window.remove_window();
})
.ok();
}
}
}
room::Event::RoomLeft { .. } => {
for (_, windows) in notification_windows.drain() {
for window in windows {
window
.update(cx, |_, window, _| {
window.remove_window();
})
.ok();
}
}
}
_ => {}
})
.detach();
}
pub struct ProjectSharedNotification {
project_id: u64,
worktree_root_names: Vec<String>,
owner: Arc<User>,
app_state: Weak<AppState>,
}
impl ProjectSharedNotification {
fn new(
owner: Arc<User>,
project_id: u64,
worktree_root_names: Vec<String>,
app_state: Weak<AppState>,
) -> Self {
Self {
project_id,
worktree_root_names,
owner,
app_state,
}
}
fn join(&mut self, cx: &mut Context<Self>) {
if let Some(app_state) = self.app_state.upgrade() {
workspace::join_in_room_project(self.project_id, self.owner.legacy_id, app_state, cx)
.detach_and_log_err(cx);
}
}
fn dismiss(&mut self, cx: &mut Context<Self>) {
if let Some(active_room) = ActiveCall::global(cx).read(cx).room().cloned() {
active_room.update(cx, |_, cx| {
cx.emit(room::Event::RemoteProjectInvitationDiscarded {
project_id: self.project_id,
});
});
}
}
}
impl Render for ProjectSharedNotification {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let ui_font = theme_settings::setup_ui_font(window, cx);
let no_worktree_root_names = self.worktree_root_names.is_empty();
let punctuation = if no_worktree_root_names { "" } else { ":" };
let main_label = format!(
"{} is sharing a project with you{}",
self.owner.github_login.clone(),
punctuation
);
div().size_full().font(ui_font).child(
CollabNotification::new(
self.owner.avatar_uri.clone(),
Button::new("open", "Open").on_click(cx.listener(move |this, _event, _, cx| {
this.join(cx);
})),
Button::new("dismiss", "Dismiss").on_click(cx.listener(
move |this, _event, _, cx| {
this.dismiss(cx);
},
)),
)
.child(Label::new(main_label))
.when(!no_worktree_root_names, |this| {
this.child(Label::new(self.worktree_root_names.join(", ")).color(Color::Muted))
}),
)
}
}

View File

@@ -0,0 +1,23 @@
use gpui::Pixels;
use settings::{RegisterSetting, Settings};
use ui::px;
use workspace::dock::DockPosition;
#[derive(Debug, RegisterSetting)]
pub struct CollaborationPanelSettings {
pub button: bool,
pub dock: DockPosition,
pub default_width: Pixels,
}
impl Settings for CollaborationPanelSettings {
fn from_settings(content: &settings::SettingsContent) -> Self {
let panel = content.collaboration_panel.as_ref().unwrap();
Self {
button: panel.button.unwrap(),
dock: panel.dock.unwrap().into(),
default_width: panel.default_width.map(px).unwrap(),
}
}
}