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
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:
78
crates/collab_ui/Cargo.toml
Normal file
78
crates/collab_ui/Cargo.toml
Normal 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"] }
|
||||
1
crates/collab_ui/LICENSE-GPL
Symbolic link
1
crates/collab_ui/LICENSE-GPL
Symbolic link
@@ -0,0 +1 @@
|
||||
../../LICENSE-GPL
|
||||
270
crates/collab_ui/src/call_stats_modal.rs
Normal file
270
crates/collab_ui/src/call_stats_modal.rs
Normal 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),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
719
crates/collab_ui/src/channel_view.rs
Normal file
719
crates/collab_ui/src/channel_view.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
4187
crates/collab_ui/src/collab_panel.rs
Normal file
4187
crates/collab_ui/src/collab_panel.rs
Normal file
File diff suppressed because it is too large
Load Diff
682
crates/collab_ui/src/collab_panel/channel_modal.rs
Normal file
682
crates/collab_ui/src/collab_panel/channel_modal.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
171
crates/collab_ui/src/collab_panel/contact_finder.rs
Normal file
171
crates/collab_ui/src/collab_panel/contact_finder.rs
Normal 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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
62
crates/collab_ui/src/collab_ui.rs
Normal file
62
crates/collab_ui/src/collab_ui.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
11
crates/collab_ui/src/notifications.rs
Normal file
11
crates/collab_ui/src/notifications.rs
Normal 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);
|
||||
}
|
||||
134
crates/collab_ui/src/notifications/incoming_call_notification.rs
Normal file
134
crates/collab_ui/src/notifications/incoming_call_notification.rs
Normal 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
|
||||
))),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
23
crates/collab_ui/src/panel_settings.rs
Normal file
23
crates/collab_ui/src/panel_settings.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user