diff --git a/assets/icons/folder_share.svg b/assets/icons/folder_share.svg new file mode 100644 index 0000000000..09a232a689 --- /dev/null +++ b/assets/icons/folder_share.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/folder_shared.svg b/assets/icons/folder_shared.svg new file mode 100644 index 0000000000..c511390305 --- /dev/null +++ b/assets/icons/folder_shared.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index bbe9d6fe8b..ff90efe851 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -140,6 +140,8 @@ pub enum IconName { FolderOpen, FolderOpenAdd, FolderSearch, + FolderShare, + FolderShared, Font, FontSize, FontWeight, diff --git a/crates/title_bar/src/collab.rs b/crates/title_bar/src/collab.rs index 64cb0e3e27..ae503e4db7 100644 --- a/crates/title_bar/src/collab.rs +++ b/crates/title_bar/src/collab.rs @@ -5,7 +5,7 @@ use call::{ActiveCall, Room}; use channel::ChannelStore; use client::{User, proto::PeerId}; use gpui::{ - AnyElement, Hsla, IntoElement, MouseButton, Path, ScreenCaptureSource, Styled, TaskExt, + AnyElement, Empty, Hsla, IntoElement, MouseButton, Path, ScreenCaptureSource, Styled, TaskExt, WeakEntity, canvas, point, }; use gpui::{App, Task, Window}; @@ -18,7 +18,8 @@ use settings::{Settings as _, SettingsLocation}; use theme::ActiveTheme; use ui::{ Avatar, AvatarAudioStatusIndicator, ContextMenu, ContextMenuItem, Divider, DividerColor, - Facepile, PopoverMenu, SplitButton, SplitButtonStyle, TintColor, Tooltip, prelude::*, + Facepile, KeyBinding, PopoverMenu, SplitButton, SplitButtonStyle, TintColor, Tooltip, + prelude::*, }; use util::rel_path::RelPath; use workspace::{ParticipantLocation, notifications::DetachAndPromptErr}; @@ -336,9 +337,9 @@ impl TitleBar { &self, window: &mut Window, cx: &mut Context, - ) -> Vec { + ) -> AnyElement { let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() else { - return Vec::new(); + return Empty.into_any_element(); }; let is_connecting_to_project = self @@ -372,8 +373,6 @@ impl TitleBar { .channel_id() .and_then(|channel_id| channel_store.read(cx).channel_for_id(channel_id).cloned()); - let mut children = Vec::new(); - let effective_quality = stats.effective_quality.unwrap_or(ConnectionQuality::Lost); let (signal_icon, signal_color, quality_label) = match effective_quality { ConnectionQuality::Excellent => { @@ -386,246 +385,305 @@ impl TitleBar { let quality_label: SharedString = quality_label.into(); - children.push( - h_flex() - .gap_1() - .child( - IconButton::new("leave-call", IconName::Exit) - .style(ButtonStyle::Subtle) - .tooltip(Tooltip::text("Leave Call")) - .icon_size(IconSize::Small) - .on_click(move |_, _window, cx| { - ActiveCall::global(cx) - .update(cx, |call, cx| call.hang_up(cx)) - .detach_and_log_err(cx); - }), - ) - .child(Divider::vertical().color(DividerColor::Border)) - .into_any_element(), - ); - - children.push( - IconButton::new("call-quality", signal_icon) - .icon_size(IconSize::Small) - .when_some(signal_color, |button, color| button.icon_color(color)) - .tooltip(move |_window, cx| { - let quality_label = quality_label.clone(); - let latency = format_stat(stats.latency_ms, |v| format!("{:.0}ms", v)); - let jitter = format_stat(stats.jitter_ms, |v| format!("{:.0}ms", v)); - let packet_loss = format_stat(stats.packet_loss_pct, |v| format!("{:.1}%", v)); - let input_lag = - format_stat(stats.input_lag.map(|d| d.as_secs_f64() * 1000.0), |v| { - format!("{:.1}ms", v) - }); - - Tooltip::with_meta( - format!("Connection: {quality_label}"), - Some(&ShowCallStats), - format!( - "Latency: {latency} · Jitter: {jitter} · Loss: {packet_loss} · Input lag: {input_lag}", - ), - cx, + h_flex() + .gap_1() + .pr_1p5() + .child( + h_flex() + .gap_1() + .child( + IconButton::new("leave-call", IconName::Exit) + .tooltip(Tooltip::text("Leave Call")) + .icon_size(IconSize::Small) + .on_click(move |_, _window, cx| { + ActiveCall::global(cx) + .update(cx, |call, cx| call.hang_up(cx)) + .detach_and_log_err(cx); + }), ) - }) - .on_click(move |_, window, cx| { - window.dispatch_action(Box::new(ShowCallStats), cx); - }) - .into_any_element(), - ); + .child(Divider::vertical().color(DividerColor::Border)), + ) + .child( + IconButton::new("call-quality", signal_icon) + .icon_size(IconSize::Small) + .when_some(signal_color, |button, color| button.icon_color(color)) + .tooltip(Tooltip::element(move |window, cx| { + let quality_label = quality_label.clone(); + let latency = format_stat(stats.latency_ms, |v| format!("{:.0}ms", v)); + let jitter = format_stat(stats.jitter_ms, |v| format!("{:.0}ms", v)); + let packet_loss = + format_stat(stats.packet_loss_pct, |v| format!("{:.1}%", v)); + let input_lag = + format_stat(stats.input_lag.map(|d| d.as_secs_f64() * 1000.0), |v| { + format!("{:.1}ms", v) + }); - if is_local && can_share_projects && !is_connecting_to_project { - let is_sharing_disabled = channel.is_some_and(|channel| match channel.visibility { - proto::ChannelVisibility::Public => project.visible_worktrees(cx).any(|worktree| { - let worktree_id = worktree.read(cx).id(); + let key_binding = KeyBinding::for_action(&ShowCallStats, cx); + let has_key_binding = key_binding.has_binding(window); - let settings_location = Some(SettingsLocation { - worktree_id, - path: RelPath::empty(), - }); + let stat_row = |label: &'static str, value: String| { + h_flex() + .justify_between() + .gap_4() + .child(Label::new(label).size(LabelSize::Small).color(Color::Muted)) + .child(Label::new(value).size(LabelSize::Small)) + }; - WorktreeSettings::get(settings_location, cx).prevent_sharing_in_public_channels - }), - proto::ChannelVisibility::Members => false, - }); - - children.push( - Button::new( - "toggle_sharing", - if is_shared { "Unshare" } else { "Share" }, + v_flex() + .child( + h_flex() + .gap_4() + .justify_between() + .child(Label::new(format!("Connection: {quality_label}"))) + .when(has_key_binding, |this| this.child(key_binding)), + ) + .child( + v_flex() + .gap_0p5() + .child(stat_row("Latency", latency)) + .child(stat_row("Jitter", jitter)) + .child(stat_row("Packet loss", packet_loss)) + .child(stat_row("Input lag", input_lag)), + ) + .into_any_element() + })) + .on_click(move |_, window, cx| { + window.dispatch_action(Box::new(ShowCallStats), cx); + }), + ) + .when(can_use_microphone, |this| { + this.child( + IconButton::new( + "mute-microphone", + if is_muted { + IconName::MicMute + } else { + IconName::Mic + }, + ) + .tooltip(move |_window, cx| { + if is_muted { + if is_deafened { + Tooltip::with_meta( + "Unmute Microphone", + None, + "Audio will be unmuted", + cx, + ) + } else { + Tooltip::simple("Unmute Microphone", cx) + } + } else { + Tooltip::simple("Mute Microphone", cx) + } + }) + .icon_size(IconSize::Small) + .toggle_state(is_muted) + .selected_style(ButtonStyle::Tinted(TintColor::Error)) + .on_click(move |_, _window, cx| toggle_mute(cx)), ) - .tooltip(Tooltip::text(if is_shared { - "Stop sharing project with call participants" - } else { - "Share project with call participants" - })) - .style(ButtonStyle::Subtle) - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .toggle_state(is_shared) - .label_size(LabelSize::Small) - .when(is_sharing_disabled, |parent| { - parent.disabled(true).tooltip(Tooltip::text( - "This project may not be shared in a public channel.", - )) - }) - .on_click(cx.listener(move |this, _, window, cx| { - if is_shared { - this.unshare_project(window, cx); - } else { - this.share_project(cx); - } - })) - .into_any_element(), - ); - } - - if can_use_microphone { - children.push( + }) + .child( IconButton::new( - "mute-microphone", - if is_muted { - IconName::MicMute + "mute-sound", + if is_deafened { + IconName::AudioOff } else { - IconName::Mic + IconName::AudioOn }, ) + .selected_style(ButtonStyle::Tinted(TintColor::Error)) + .icon_size(IconSize::Small) + .toggle_state(is_deafened) .tooltip(move |_window, cx| { - if is_muted { - if is_deafened { - Tooltip::with_meta( - "Unmute Microphone", - None, - "Audio will be unmuted", - cx, - ) + if is_deafened { + let label = "Unmute Audio"; + + if !muted_by_user { + Tooltip::with_meta(label, None, "Microphone will be unmuted", cx) } else { - Tooltip::simple("Unmute Microphone", cx) + Tooltip::simple(label, cx) } } else { - Tooltip::simple("Mute Microphone", cx) + let label = "Mute Audio"; + + if !muted_by_user { + Tooltip::with_meta(label, None, "Microphone will be muted", cx) + } else { + Tooltip::simple(label, cx) + } } }) - .style(ButtonStyle::Subtle) - .icon_size(IconSize::Small) - .toggle_state(is_muted) - .selected_style(ButtonStyle::Tinted(TintColor::Error)) - .on_click(move |_, _window, cx| toggle_mute(cx)) - .into_any_element(), - ); - } + .on_click(move |_, _, cx| toggle_deafen(cx)), + ) + .when( + is_local && can_share_projects && !is_connecting_to_project, + |this| { + let is_sharing_disabled = + channel.is_some_and(|channel| match channel.visibility { + proto::ChannelVisibility::Public => { + project.visible_worktrees(cx).any(|worktree| { + let worktree_id = worktree.read(cx).id(); - children.push( - IconButton::new( - "mute-sound", - if is_deafened { - IconName::AudioOff - } else { - IconName::AudioOn + let settings_location = Some(SettingsLocation { + worktree_id, + path: RelPath::empty(), + }); + + WorktreeSettings::get(settings_location, cx) + .prevent_sharing_in_public_channels + }) + } + proto::ChannelVisibility::Members => false, + }); + + let icon = if is_shared { + IconName::FolderShared + } else { + IconName::FolderShare + }; + + let folder_names = project + .visible_worktrees(cx) + .filter_map(|worktree| { + worktree + .read(cx) + .root_name() + .file_name() + .map(|name| name.to_string()) + }) + .collect::>(); + let folder_list = folder_names.join(", "); + + let unshare_meta: SharedString = if folder_list.is_empty() { + "Stop sharing project with call participants".into() + } else { + format!("Stop sharing {folder_list} with call participants").into() + }; + let share_meta: SharedString = if folder_list.is_empty() { + "Share active project with call participants".into() + } else { + format!("Share {folder_list} with call participants").into() + }; + + this.child( + IconButton::new("toggle_sharing", icon) + .icon_size(IconSize::Small) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .toggle_state(is_shared) + .map(|this| { + if is_shared { + this.tooltip(move |_, cx| { + Tooltip::with_meta( + "Unshare Project", + None, + unshare_meta.clone(), + cx, + ) + }) + .on_click(cx.listener( + move |this, _, window, cx| { + this.unshare_project(window, cx); + }, + )) + } else if is_sharing_disabled { + this.disabled(true).tooltip(Tooltip::text( + "This project may not be shared in a public channel.", + )) + } else { + this.tooltip(move |_, cx| { + Tooltip::with_meta( + "Share Project", + None, + share_meta.clone(), + cx, + ) + }) + .on_click(cx.listener( + move |this, _, _, cx| { + this.share_project(cx); + }, + )) + } + }), + ) }, ) - .style(ButtonStyle::Subtle) - .selected_style(ButtonStyle::Tinted(TintColor::Error)) - .icon_size(IconSize::Small) - .toggle_state(is_deafened) - .tooltip(move |_window, cx| { - if is_deafened { - let label = "Unmute Audio"; + .when(can_use_microphone && screen_sharing_supported, |parent| { + #[cfg(target_os = "linux")] + let is_wayland = gpui::guess_compositor() == "Wayland"; + #[cfg(not(target_os = "linux"))] + let is_wayland = false; - if !muted_by_user { - Tooltip::with_meta(label, None, "Microphone will be unmuted", cx) + let trigger = IconButton::new("screen-share", IconName::Screen) + .style(ButtonStyle::Subtle) + .icon_size(IconSize::Small) + .toggle_state(is_screen_sharing) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .tooltip(Tooltip::text(if is_screen_sharing { + "Stop Sharing Screen" } else { - Tooltip::simple(label, cx) - } + "Share Screen" + })) + .on_click(move |_, window, cx| { + let should_share = ActiveCall::global(cx) + .read(cx) + .room() + .is_some_and(|room| !room.read(cx).is_sharing_screen()); + + #[cfg(target_os = "linux")] + { + if is_wayland + && let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() + { + let task = room.update(cx, |room, cx| { + if should_share { + room.share_screen_wayland(cx) + } else { + room.unshare_screen(true, cx) + .map(|()| Task::ready(Ok(()))) + .unwrap_or_else(|e| Task::ready(Err(e))) + } + }); + task.detach_and_prompt_err( + "Sharing Screen Failed", + window, + cx, + |e, _, _| Some(format!("{e:?}")), + ); + } + } + if !is_wayland { + window + .spawn(cx, async move |cx| { + let screen = if should_share { + cx.update(|_, cx| pick_default_screen(cx))?.await + } else { + Ok(None) + }; + cx.update(|window, cx| { + toggle_screen_sharing(screen, window, cx) + })?; + + Result::<_, anyhow::Error>::Ok(()) + }) + .detach(); + } + }); + + if is_wayland { + parent.child(trigger) } else { - let label = "Mute Audio"; - - if !muted_by_user { - Tooltip::with_meta(label, None, "Microphone will be muted", cx) - } else { - Tooltip::simple(label, cx) - } + parent.child( + SplitButton::new( + trigger.render(window, cx), + self.render_screen_list().into_any_element(), + ) + .style(SplitButtonStyle::Transparent), + ) } }) - .on_click(move |_, _, cx| toggle_deafen(cx)) - .into_any_element(), - ); - - if can_use_microphone && screen_sharing_supported { - #[cfg(target_os = "linux")] - let is_wayland = gpui::guess_compositor() == "Wayland"; - #[cfg(not(target_os = "linux"))] - let is_wayland = false; - - let trigger = IconButton::new("screen-share", IconName::Screen) - .style(ButtonStyle::Subtle) - .icon_size(IconSize::Small) - .toggle_state(is_screen_sharing) - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .tooltip(Tooltip::text(if is_screen_sharing { - "Stop Sharing Screen" - } else { - "Share Screen" - })) - .on_click(move |_, window, cx| { - let should_share = ActiveCall::global(cx) - .read(cx) - .room() - .is_some_and(|room| !room.read(cx).is_sharing_screen()); - - #[cfg(target_os = "linux")] - { - if is_wayland - && let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() - { - let task = room.update(cx, |room, cx| { - if should_share { - room.share_screen_wayland(cx) - } else { - room.unshare_screen(true, cx) - .map(|()| Task::ready(Ok(()))) - .unwrap_or_else(|e| Task::ready(Err(e))) - } - }); - task.detach_and_prompt_err( - "Sharing Screen Failed", - window, - cx, - |e, _, _| Some(format!("{e:?}")), - ); - } - } - if !is_wayland { - window - .spawn(cx, async move |cx| { - let screen = if should_share { - cx.update(|_, cx| pick_default_screen(cx))?.await - } else { - Ok(None) - }; - cx.update(|window, cx| toggle_screen_sharing(screen, window, cx))?; - - Result::<_, anyhow::Error>::Ok(()) - }) - .detach(); - } - }); - - if is_wayland { - children.push(trigger.into_any_element()); - } else { - children.push( - SplitButton::new( - trigger.render(window, cx), - self.render_screen_list().into_any_element(), - ) - .style(SplitButtonStyle::Transparent) - .into_any_element(), - ); - } - } - - children.push(div().pr_2().into_any_element()); - - children + .into_any_element() } fn render_screen_list(&self) -> impl IntoElement { diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 85f2905ea8..1da19d6250 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -362,7 +362,7 @@ impl Render for TitleBar { }) .gap_1() .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .children(self.render_call_controls(window, cx)) + .child(self.render_call_controls(window, cx)) .children(self.render_connection_status(status, cx)) .child(self.update_version.clone()) .when(