logiguard fork: GPUI xdg-activation keyboard-focus serial fix
Update All Top Ranking Issues / update_top_ranking_issues (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_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
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (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
Update All Top Ranking Issues / update_top_ranking_issues (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_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
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (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:
@@ -0,0 +1,81 @@
|
||||
[package]
|
||||
name = "title_bar"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/title_bar.rs"
|
||||
doctest = false
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
test-support = [
|
||||
"call/test-support",
|
||||
"client/test-support",
|
||||
|
||||
"gpui/test-support",
|
||||
|
||||
"project/test-support",
|
||||
"remote/test-support",
|
||||
"util/test-support",
|
||||
"workspace/test-support",
|
||||
"recent_projects/test-support",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
agent_settings.workspace = true
|
||||
anyhow.workspace = true
|
||||
auto_update.workspace = true
|
||||
fs.workspace = true
|
||||
platform_title_bar.workspace = true
|
||||
call.workspace = true
|
||||
channel.workspace = true
|
||||
chrono.workspace = true
|
||||
client.workspace = true
|
||||
cloud_api_types.workspace = true
|
||||
db.workspace = true
|
||||
git_ui.workspace = true
|
||||
gpui = { workspace = true, features = ["screen-capture"] }
|
||||
icons.workspace = true
|
||||
livekit_client.workspace = true
|
||||
notifications.workspace = true
|
||||
project.workspace = true
|
||||
recent_projects.workspace = true
|
||||
remote.workspace = true
|
||||
remote_connection.workspace = true
|
||||
rpc.workspace = true
|
||||
semver.workspace = true
|
||||
schemars.workspace = true
|
||||
serde.workspace = true
|
||||
settings.workspace = true
|
||||
smallvec.workspace = true
|
||||
telemetry.workspace = true
|
||||
theme.workspace = true
|
||||
ui.workspace = true
|
||||
util.workspace = true
|
||||
workspace.workspace = true
|
||||
zed_actions.workspace = true
|
||||
arrayvec = "0.7.6"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
call = { workspace = true, features = ["test-support"] }
|
||||
client = { workspace = true, features = ["test-support"] }
|
||||
gpui = { workspace = true, features = ["test-support"] }
|
||||
notifications = { workspace = true, features = ["test-support"] }
|
||||
project = { workspace = true, features = ["test-support"] }
|
||||
release_channel.workspace = true
|
||||
remote = { workspace = true, features = ["test-support"] }
|
||||
rpc = { workspace = true, features = ["test-support"] }
|
||||
semver.workspace = true
|
||||
settings = { workspace = true, features = ["test-support"] }
|
||||
util = { workspace = true, features = ["test-support"] }
|
||||
workspace = { workspace = true, features = ["test-support"] }
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../LICENSE-GPL
|
||||
@@ -0,0 +1,28 @@
|
||||
#![allow(clippy::disallowed_methods, reason = "build scripts are exempt")]
|
||||
|
||||
fn main() {
|
||||
println!("cargo::rustc-check-cfg=cfg(macos_sdk_26)");
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
use std::process::Command;
|
||||
|
||||
let output = Command::new("xcrun")
|
||||
.args(["--sdk", "macosx", "--show-sdk-version"])
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let sdk_version = String::from_utf8(output.stdout).unwrap();
|
||||
let major_version: Option<u32> = sdk_version
|
||||
.trim()
|
||||
.split('.')
|
||||
.next()
|
||||
.and_then(|v| v.parse().ok());
|
||||
|
||||
if let Some(major) = major_version
|
||||
&& major >= 26
|
||||
{
|
||||
println!("cargo:rustc-cfg=macos_sdk_26");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
use gpui::{Action, Entity, OwnedMenu, OwnedMenuItem, actions};
|
||||
use settings::Settings;
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
|
||||
use smallvec::SmallVec;
|
||||
use ui::{ContextMenu, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*};
|
||||
|
||||
use crate::title_bar_settings::TitleBarSettings;
|
||||
|
||||
actions!(
|
||||
app_menu,
|
||||
[
|
||||
/// Activates the menu on the right in the client-side application menu.
|
||||
///
|
||||
/// Does not apply to platform menu bars (e.g. on macOS).
|
||||
ActivateMenuRight,
|
||||
/// Activates the menu on the left in the client-side application menu.
|
||||
///
|
||||
/// Does not apply to platform menu bars (e.g. on macOS).
|
||||
ActivateMenuLeft
|
||||
]
|
||||
);
|
||||
|
||||
/// Opens the named menu in the client-side application menu.
|
||||
///
|
||||
/// Does not apply to platform menu bars (e.g. on macOS).
|
||||
#[derive(Clone, Deserialize, JsonSchema, PartialEq, Default, Action)]
|
||||
#[action(namespace = app_menu)]
|
||||
pub struct OpenApplicationMenu(String);
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub enum ActivateDirection {
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct MenuEntry {
|
||||
menu: OwnedMenu,
|
||||
handle: PopoverMenuHandle<ContextMenu>,
|
||||
}
|
||||
|
||||
pub struct ApplicationMenu {
|
||||
entries: SmallVec<[MenuEntry; 8]>,
|
||||
pending_menu_open: Option<String>,
|
||||
}
|
||||
|
||||
impl ApplicationMenu {
|
||||
pub fn new(_: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let menus = cx.get_menus().unwrap_or_default();
|
||||
Self {
|
||||
entries: menus
|
||||
.into_iter()
|
||||
.map(|menu| MenuEntry {
|
||||
menu,
|
||||
handle: PopoverMenuHandle::default(),
|
||||
})
|
||||
.collect(),
|
||||
pending_menu_open: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_menu_items(items: Vec<OwnedMenuItem>) -> Vec<OwnedMenuItem> {
|
||||
let mut cleaned = Vec::new();
|
||||
let mut last_was_separator = false;
|
||||
|
||||
for item in items {
|
||||
match item {
|
||||
OwnedMenuItem::Separator => {
|
||||
if !last_was_separator {
|
||||
cleaned.push(item);
|
||||
last_was_separator = true;
|
||||
}
|
||||
}
|
||||
OwnedMenuItem::Submenu(submenu) => {
|
||||
// Skip empty submenus
|
||||
if !submenu.items.is_empty() {
|
||||
cleaned.push(OwnedMenuItem::Submenu(submenu));
|
||||
last_was_separator = false;
|
||||
}
|
||||
}
|
||||
item => {
|
||||
cleaned.push(item);
|
||||
last_was_separator = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove trailing separator
|
||||
if let Some(OwnedMenuItem::Separator) = cleaned.last() {
|
||||
cleaned.pop();
|
||||
}
|
||||
|
||||
cleaned
|
||||
}
|
||||
|
||||
fn build_menu_from_items(
|
||||
entry: MenuEntry,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<ContextMenu> {
|
||||
ContextMenu::build(window, cx, |menu, window, cx| {
|
||||
// Grab current focus handle so menu can shown items in context with the focused element
|
||||
let menu = menu.when_some(window.focused(cx), |menu, focused| menu.context(focused));
|
||||
let sanitized_items = Self::sanitize_menu_items(entry.menu.items);
|
||||
|
||||
sanitized_items
|
||||
.into_iter()
|
||||
.fold(menu, |menu, item| match item {
|
||||
OwnedMenuItem::Separator => menu.separator(),
|
||||
OwnedMenuItem::Action {
|
||||
name,
|
||||
action,
|
||||
checked,
|
||||
disabled,
|
||||
..
|
||||
} => menu.action_checked_with_disabled(name, action, checked, disabled),
|
||||
OwnedMenuItem::Submenu(submenu) => {
|
||||
submenu
|
||||
.items
|
||||
.into_iter()
|
||||
.fold(menu, |menu, item| match item {
|
||||
OwnedMenuItem::Separator => menu.separator(),
|
||||
OwnedMenuItem::Action {
|
||||
name,
|
||||
action,
|
||||
checked,
|
||||
disabled,
|
||||
..
|
||||
} => menu
|
||||
.action_checked_with_disabled(name, action, checked, disabled),
|
||||
OwnedMenuItem::Submenu(_) => menu,
|
||||
OwnedMenuItem::SystemMenu(_) => {
|
||||
// A system menu doesn't make sense in this context, so ignore it
|
||||
menu
|
||||
}
|
||||
})
|
||||
}
|
||||
OwnedMenuItem::SystemMenu(_) => {
|
||||
// A system menu doesn't make sense in this context, so ignore it
|
||||
menu
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn render_application_menu(&self, entry: &MenuEntry) -> impl IntoElement {
|
||||
let handle = entry.handle.clone();
|
||||
|
||||
let menu_name = entry.menu.name.clone();
|
||||
let entry = entry.clone();
|
||||
|
||||
// Application menu must have same ids as first menu item in standard menu
|
||||
div()
|
||||
.id(format!("{}-menu-item", menu_name))
|
||||
.occlude()
|
||||
.child(
|
||||
PopoverMenu::new(format!("{}-menu-popover", menu_name))
|
||||
.menu(move |window, cx| {
|
||||
Self::build_menu_from_items(entry.clone(), window, cx).into()
|
||||
})
|
||||
.trigger_with_tooltip(
|
||||
IconButton::new(
|
||||
SharedString::from(format!("{}-menu-trigger", menu_name)),
|
||||
ui::IconName::Menu,
|
||||
)
|
||||
.style(ButtonStyle::Subtle)
|
||||
.icon_size(IconSize::Small),
|
||||
Tooltip::text("Open Application Menu"),
|
||||
)
|
||||
.with_handle(handle),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_standard_menu(&self, entry: &MenuEntry) -> impl IntoElement {
|
||||
let current_handle = entry.handle.clone();
|
||||
|
||||
let menu_name = entry.menu.name.clone();
|
||||
let entry = entry.clone();
|
||||
|
||||
let all_handles: Vec<_> = self
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| entry.handle.clone())
|
||||
.collect();
|
||||
|
||||
div()
|
||||
.id(format!("{}-menu-item", menu_name))
|
||||
.occlude()
|
||||
.child(
|
||||
PopoverMenu::new(format!("{}-menu-popover", menu_name))
|
||||
.menu(move |window, cx| {
|
||||
Self::build_menu_from_items(entry.clone(), window, cx).into()
|
||||
})
|
||||
.trigger(
|
||||
Button::new(
|
||||
SharedString::from(format!("{}-menu-trigger", menu_name)),
|
||||
menu_name,
|
||||
)
|
||||
.style(ButtonStyle::Subtle)
|
||||
.label_size(LabelSize::Small),
|
||||
)
|
||||
.with_handle(current_handle.clone()),
|
||||
)
|
||||
.on_hover(move |hover_enter, window, cx| {
|
||||
if *hover_enter && !current_handle.is_deployed() {
|
||||
all_handles.iter().for_each(|h| h.hide(cx));
|
||||
|
||||
// We need to defer this so that this menu handle can take focus from the previous menu
|
||||
let handle = current_handle.clone();
|
||||
window.defer(cx, move |window, cx| handle.show(window, cx));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn open_menu(
|
||||
&mut self,
|
||||
action: &OpenApplicationMenu,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) {
|
||||
self.pending_menu_open = Some(action.0.clone());
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn navigate_menus_in_direction(
|
||||
&mut self,
|
||||
direction: ActivateDirection,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let current_index = self
|
||||
.entries
|
||||
.iter()
|
||||
.position(|entry| entry.handle.is_deployed());
|
||||
let Some(current_index) = current_index else {
|
||||
return;
|
||||
};
|
||||
|
||||
let next_index = match direction {
|
||||
ActivateDirection::Left => {
|
||||
if current_index == 0 {
|
||||
self.entries.len() - 1
|
||||
} else {
|
||||
current_index - 1
|
||||
}
|
||||
}
|
||||
ActivateDirection::Right => {
|
||||
if current_index == self.entries.len() - 1 {
|
||||
0
|
||||
} else {
|
||||
current_index + 1
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
self.entries[current_index].handle.hide(cx);
|
||||
|
||||
// We need to defer this so that this menu handle can take focus from the previous menu
|
||||
let next_handle = self.entries[next_index].handle.clone();
|
||||
cx.defer_in(window, move |_, window, cx| next_handle.show(window, cx));
|
||||
}
|
||||
|
||||
pub fn all_menus_shown(&self, cx: &mut Context<Self>) -> bool {
|
||||
show_menus(cx)
|
||||
|| self.entries.iter().any(|entry| entry.handle.is_deployed())
|
||||
|| self.pending_menu_open.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn show_menus(cx: &mut App) -> bool {
|
||||
TitleBarSettings::get_global(cx).show_menus
|
||||
&& (cfg!(not(target_os = "macos")) || option_env!("ZED_USE_CROSS_PLATFORM_MENU").is_some())
|
||||
}
|
||||
|
||||
impl Render for ApplicationMenu {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let all_menus_shown = self.all_menus_shown(cx);
|
||||
|
||||
if let Some(pending_menu_open) = self.pending_menu_open.take()
|
||||
&& let Some(entry) = self
|
||||
.entries
|
||||
.iter()
|
||||
.find(|entry| entry.menu.name == pending_menu_open && !entry.handle.is_deployed())
|
||||
{
|
||||
let handle_to_show = entry.handle.clone();
|
||||
let handles_to_hide: Vec<_> = self
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| e.menu.name != pending_menu_open && e.handle.is_deployed())
|
||||
.map(|e| e.handle.clone())
|
||||
.collect();
|
||||
|
||||
if handles_to_hide.is_empty() {
|
||||
// We need to wait for the next frame to show all menus first,
|
||||
// before we can handle show/hide operations
|
||||
window.on_next_frame(move |window, cx| {
|
||||
handles_to_hide.iter().for_each(|handle| handle.hide(cx));
|
||||
window.defer(cx, move |window, cx| handle_to_show.show(window, cx));
|
||||
});
|
||||
} else {
|
||||
// Since menus are already shown, we can directly handle show/hide operations
|
||||
handles_to_hide.iter().for_each(|handle| handle.hide(cx));
|
||||
cx.defer_in(window, move |_, window, cx| handle_to_show.show(window, cx));
|
||||
}
|
||||
}
|
||||
|
||||
div()
|
||||
.key_context("ApplicationMenu")
|
||||
.flex()
|
||||
.flex_row()
|
||||
.gap_x_1()
|
||||
.when(!all_menus_shown && !self.entries.is_empty(), |this| {
|
||||
this.child(self.render_application_menu(&self.entries[0]))
|
||||
})
|
||||
.when(all_menus_shown, |this| {
|
||||
this.children(
|
||||
self.entries
|
||||
.iter()
|
||||
.map(|entry| self.render_standard_menu(entry)),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,722 @@
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use call::{ActiveCall, Room};
|
||||
use channel::ChannelStore;
|
||||
use client::{User, proto::PeerId};
|
||||
use gpui::{
|
||||
AnyElement, Hsla, IntoElement, MouseButton, Path, ScreenCaptureSource, Styled, TaskExt,
|
||||
WeakEntity, canvas, point,
|
||||
};
|
||||
use gpui::{App, Task, Window};
|
||||
use icons::IconName;
|
||||
use livekit_client::ConnectionQuality;
|
||||
use project::WorktreeSettings;
|
||||
use remote_connection::RemoteConnectionModal;
|
||||
use rpc::proto::{self};
|
||||
use settings::{Settings as _, SettingsLocation};
|
||||
use theme::ActiveTheme;
|
||||
use ui::{
|
||||
Avatar, AvatarAudioStatusIndicator, ContextMenu, ContextMenuItem, Divider, DividerColor,
|
||||
Facepile, PopoverMenu, SplitButton, SplitButtonStyle, TintColor, Tooltip, prelude::*,
|
||||
};
|
||||
use util::rel_path::RelPath;
|
||||
use workspace::{ParticipantLocation, notifications::DetachAndPromptErr};
|
||||
use zed_actions::ShowCallStats;
|
||||
|
||||
use crate::TitleBar;
|
||||
|
||||
fn format_stat(value: Option<f64>, format: impl Fn(f64) -> String) -> String {
|
||||
match value {
|
||||
Some(v) => format(v),
|
||||
None => "—".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_screen_sharing(
|
||||
screen: anyhow::Result<Option<Rc<dyn ScreenCaptureSource>>>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let call = ActiveCall::global(cx).read(cx);
|
||||
let toggle_screen_sharing = match screen {
|
||||
Ok(screen) => {
|
||||
let Some(room) = call.room().cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
room.update(cx, |room, cx| {
|
||||
let clicked_on_currently_shared_screen =
|
||||
room.shared_screen_id().is_some_and(|screen_id| {
|
||||
Some(screen_id)
|
||||
== screen
|
||||
.as_deref()
|
||||
.and_then(|s| s.metadata().ok().map(|meta| meta.id))
|
||||
});
|
||||
let should_unshare_current_screen = room.is_sharing_screen();
|
||||
let unshared_current_screen = should_unshare_current_screen.then(|| {
|
||||
telemetry::event!(
|
||||
"Screen Share Disabled",
|
||||
room_id = room.id(),
|
||||
channel_id = room.channel_id(),
|
||||
);
|
||||
room.unshare_screen(clicked_on_currently_shared_screen || screen.is_none(), cx)
|
||||
});
|
||||
if let Some(screen) = screen {
|
||||
if !should_unshare_current_screen {
|
||||
telemetry::event!(
|
||||
"Screen Share Enabled",
|
||||
room_id = room.id(),
|
||||
channel_id = room.channel_id(),
|
||||
);
|
||||
}
|
||||
cx.spawn(async move |room, cx| {
|
||||
unshared_current_screen.transpose()?;
|
||||
if !clicked_on_currently_shared_screen {
|
||||
room.update(cx, |room, cx| room.share_screen(screen, cx))?
|
||||
.await
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
} else {
|
||||
Task::ready(Ok(()))
|
||||
}
|
||||
})
|
||||
}
|
||||
Err(e) => Task::ready(Err(e)),
|
||||
};
|
||||
toggle_screen_sharing.detach_and_prompt_err("Sharing Screen Failed", window, cx, |e, _, _| Some(format!("{:?}\n\nPlease check that you have given Zed permissions to record your screen in Settings.", e)));
|
||||
}
|
||||
|
||||
pub fn toggle_mute(cx: &mut App) {
|
||||
let call = ActiveCall::global(cx).read(cx);
|
||||
if let Some(room) = call.room().cloned() {
|
||||
room.update(cx, |room, cx| {
|
||||
let operation = if room.is_muted() {
|
||||
"Microphone Enabled"
|
||||
} else {
|
||||
"Microphone Disabled"
|
||||
};
|
||||
telemetry::event!(
|
||||
operation,
|
||||
room_id = room.id(),
|
||||
channel_id = room.channel_id(),
|
||||
);
|
||||
|
||||
room.toggle_mute(cx)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_deafen(cx: &mut App) {
|
||||
if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() {
|
||||
room.update(cx, |room, cx| room.toggle_deafen(cx));
|
||||
}
|
||||
}
|
||||
|
||||
fn render_color_ribbon(color: Hsla) -> impl Element {
|
||||
canvas(
|
||||
move |_, _, _| {},
|
||||
move |bounds, _, window, _| {
|
||||
let height = bounds.size.height;
|
||||
let horizontal_offset = height;
|
||||
let vertical_offset = height / 2.0;
|
||||
let mut path = Path::new(bounds.bottom_left());
|
||||
path.curve_to(
|
||||
bounds.origin + point(horizontal_offset, vertical_offset),
|
||||
bounds.origin + point(px(0.0), vertical_offset),
|
||||
);
|
||||
path.line_to(bounds.top_right() + point(-horizontal_offset, vertical_offset));
|
||||
path.curve_to(
|
||||
bounds.bottom_right(),
|
||||
bounds.top_right() + point(px(0.0), vertical_offset),
|
||||
);
|
||||
path.line_to(bounds.bottom_left());
|
||||
window.paint_path(path, color);
|
||||
},
|
||||
)
|
||||
.h_1()
|
||||
.w_full()
|
||||
}
|
||||
|
||||
impl TitleBar {
|
||||
pub(crate) fn render_collaborator_list(
|
||||
&self,
|
||||
_: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement {
|
||||
let room = ActiveCall::global(cx).read(cx).room().cloned();
|
||||
let current_user = self.user_store.read(cx).current_user();
|
||||
let client = self.client.clone();
|
||||
let project_id = self.project.read(cx).remote_id();
|
||||
let workspace = self.workspace.upgrade();
|
||||
|
||||
h_flex()
|
||||
.id("collaborator-list")
|
||||
.w_full()
|
||||
.gap_1()
|
||||
.overflow_x_scroll()
|
||||
.when_some(
|
||||
current_user.zip(client.peer_id()).zip(room),
|
||||
|this, ((current_user, peer_id), room)| {
|
||||
let player_colors = cx.theme().players();
|
||||
let room = room.read(cx);
|
||||
let mut remote_participants =
|
||||
room.remote_participants().values().collect::<Vec<_>>();
|
||||
remote_participants.sort_by_key(|p| p.participant_index.0);
|
||||
|
||||
let current_user_face_pile = self.render_collaborator(
|
||||
¤t_user,
|
||||
peer_id,
|
||||
true,
|
||||
room.is_speaking(),
|
||||
room.is_muted(),
|
||||
None,
|
||||
room,
|
||||
project_id,
|
||||
¤t_user,
|
||||
cx,
|
||||
);
|
||||
|
||||
this.children(current_user_face_pile.map(|face_pile| {
|
||||
v_flex()
|
||||
.on_mouse_down(MouseButton::Left, |_, window, _| {
|
||||
window.prevent_default()
|
||||
})
|
||||
.child(face_pile)
|
||||
.child(render_color_ribbon(player_colors.local().cursor))
|
||||
}))
|
||||
.children(remote_participants.iter().filter_map(|collaborator| {
|
||||
let player_color =
|
||||
player_colors.color_for_participant(collaborator.participant_index.0);
|
||||
let is_following = workspace
|
||||
.as_ref()?
|
||||
.read(cx)
|
||||
.is_being_followed(collaborator.peer_id);
|
||||
let is_present = project_id.is_some_and(|project_id| {
|
||||
collaborator.location
|
||||
== ParticipantLocation::SharedProject { project_id }
|
||||
});
|
||||
|
||||
let facepile = self.render_collaborator(
|
||||
&collaborator.user,
|
||||
collaborator.peer_id,
|
||||
is_present,
|
||||
collaborator.speaking,
|
||||
collaborator.muted,
|
||||
is_following.then_some(player_color.selection),
|
||||
room,
|
||||
project_id,
|
||||
¤t_user,
|
||||
cx,
|
||||
)?;
|
||||
|
||||
Some(
|
||||
v_flex()
|
||||
.id(("collaborator", collaborator.user.legacy_id))
|
||||
.child(facepile)
|
||||
.child(render_color_ribbon(player_color.cursor))
|
||||
.cursor_pointer()
|
||||
.on_mouse_down(MouseButton::Left, |_, window, _| {
|
||||
window.prevent_default()
|
||||
})
|
||||
.on_click({
|
||||
let peer_id = collaborator.peer_id;
|
||||
cx.listener(move |this, _, window, cx| {
|
||||
cx.stop_propagation();
|
||||
|
||||
this.workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
if is_following {
|
||||
workspace.unfollow(peer_id, window, cx);
|
||||
} else {
|
||||
workspace.follow(peer_id, window, cx);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
})
|
||||
.occlude()
|
||||
.tooltip({
|
||||
let login = collaborator.user.github_login.clone();
|
||||
Tooltip::text(format!("Follow {login}"))
|
||||
}),
|
||||
)
|
||||
}))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn render_collaborator(
|
||||
&self,
|
||||
user: &Arc<User>,
|
||||
peer_id: PeerId,
|
||||
is_present: bool,
|
||||
is_speaking: bool,
|
||||
is_muted: bool,
|
||||
leader_selection_color: Option<Hsla>,
|
||||
room: &Room,
|
||||
project_id: Option<u64>,
|
||||
current_user: &Arc<User>,
|
||||
cx: &App,
|
||||
) -> Option<Div> {
|
||||
if room.role_for_user(user.legacy_id) == Some(proto::ChannelRole::Guest) {
|
||||
return None;
|
||||
}
|
||||
|
||||
const FACEPILE_LIMIT: usize = 3;
|
||||
let followers = project_id.map_or(&[] as &[_], |id| room.followers_for(peer_id, id));
|
||||
let extra_count = followers.len().saturating_sub(FACEPILE_LIMIT);
|
||||
|
||||
Some(
|
||||
div()
|
||||
.m_0p5()
|
||||
.p_0p5()
|
||||
// When the collaborator is not followed, still draw this wrapper div, but leave
|
||||
// it transparent, so that it does not shift the layout when following.
|
||||
.when_some(leader_selection_color, |div, color| {
|
||||
div.rounded_sm().bg(color)
|
||||
})
|
||||
.child(
|
||||
Facepile::empty()
|
||||
.child(
|
||||
Avatar::new(user.avatar_uri.clone())
|
||||
.grayscale(!is_present)
|
||||
.border_color(if is_speaking {
|
||||
cx.theme().status().info
|
||||
} else {
|
||||
// We draw the border in a transparent color rather to avoid
|
||||
// the layout shift that would come with adding/removing the border.
|
||||
gpui::transparent_black()
|
||||
})
|
||||
.when(is_muted, |avatar| {
|
||||
avatar.indicator(
|
||||
AvatarAudioStatusIndicator::new(ui::AudioStatus::Muted)
|
||||
.tooltip({
|
||||
let github_login = user.github_login.clone();
|
||||
Tooltip::text(format!("{} is muted", github_login))
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.children(followers.iter().take(FACEPILE_LIMIT).filter_map(
|
||||
|follower_peer_id| {
|
||||
let follower = room
|
||||
.remote_participants()
|
||||
.values()
|
||||
.find_map(|p| {
|
||||
(p.peer_id == *follower_peer_id).then_some(&p.user)
|
||||
})
|
||||
.or_else(|| {
|
||||
(self.client.peer_id() == Some(*follower_peer_id))
|
||||
.then_some(current_user)
|
||||
})?
|
||||
.clone();
|
||||
|
||||
Some(div().mt(-px(4.)).child(
|
||||
Avatar::new(follower.avatar_uri.clone()).size(rems(0.75)),
|
||||
))
|
||||
},
|
||||
))
|
||||
.children(if extra_count > 0 {
|
||||
Some(
|
||||
Label::new(format!("+{extra_count}"))
|
||||
.ml_1()
|
||||
.into_any_element(),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn render_call_controls(
|
||||
&self,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Vec<AnyElement> {
|
||||
let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let is_connecting_to_project = self
|
||||
.workspace
|
||||
.update(cx, |workspace, cx| {
|
||||
workspace
|
||||
.active_modal::<RemoteConnectionModal>(cx)
|
||||
.is_some()
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
let room = room.read(cx);
|
||||
let project = self.project.read(cx);
|
||||
let is_local = project.is_local() || project.is_via_remote_server();
|
||||
let is_shared = is_local && project.is_shared();
|
||||
let is_muted = room.is_muted();
|
||||
let muted_by_user = room.muted_by_user();
|
||||
let is_deafened = room.is_deafened().unwrap_or(false);
|
||||
let is_screen_sharing = room.is_sharing_screen();
|
||||
let can_use_microphone = room.can_use_microphone();
|
||||
let can_share_projects = room.can_share_projects();
|
||||
let screen_sharing_supported = cx.is_screen_capture_supported();
|
||||
|
||||
let stats = room
|
||||
.diagnostics()
|
||||
.map(|d| d.read(cx).stats().clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
let channel_store = ChannelStore::global(cx);
|
||||
let channel = room
|
||||
.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 => {
|
||||
(IconName::SignalHigh, Some(Color::Success), "Excellent")
|
||||
}
|
||||
ConnectionQuality::Good => (IconName::SignalHigh, None, "Good"),
|
||||
ConnectionQuality::Poor => (IconName::SignalMedium, Some(Color::Warning), "Poor"),
|
||||
ConnectionQuality::Lost => (IconName::SignalLow, Some(Color::Error), "Lost"),
|
||||
};
|
||||
|
||||
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,
|
||||
)
|
||||
})
|
||||
.on_click(move |_, window, cx| {
|
||||
window.dispatch_action(Box::new(ShowCallStats), cx);
|
||||
})
|
||||
.into_any_element(),
|
||||
);
|
||||
|
||||
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 settings_location = Some(SettingsLocation {
|
||||
worktree_id,
|
||||
path: RelPath::empty(),
|
||||
});
|
||||
|
||||
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" },
|
||||
)
|
||||
.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(
|
||||
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)
|
||||
}
|
||||
})
|
||||
.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(),
|
||||
);
|
||||
}
|
||||
|
||||
children.push(
|
||||
IconButton::new(
|
||||
"mute-sound",
|
||||
if is_deafened {
|
||||
IconName::AudioOff
|
||||
} else {
|
||||
IconName::AudioOn
|
||||
},
|
||||
)
|
||||
.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";
|
||||
|
||||
if !muted_by_user {
|
||||
Tooltip::with_meta(label, None, "Microphone will be unmuted", cx)
|
||||
} else {
|
||||
Tooltip::simple(label, cx)
|
||||
}
|
||||
} else {
|
||||
let label = "Mute Audio";
|
||||
|
||||
if !muted_by_user {
|
||||
Tooltip::with_meta(label, None, "Microphone will be muted", cx)
|
||||
} else {
|
||||
Tooltip::simple(label, cx)
|
||||
}
|
||||
}
|
||||
})
|
||||
.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
|
||||
}
|
||||
|
||||
fn render_screen_list(&self) -> impl IntoElement {
|
||||
PopoverMenu::new("screen-share-screen-list")
|
||||
.with_handle(self.screen_share_popover_handle.clone())
|
||||
.trigger(
|
||||
ui::ButtonLike::new_rounded_right("screen-share-screen-list-trigger")
|
||||
.child(
|
||||
h_flex()
|
||||
.mx_neg_0p5()
|
||||
.h_full()
|
||||
.justify_center()
|
||||
.child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)),
|
||||
)
|
||||
.toggle_state(self.screen_share_popover_handle.is_deployed()),
|
||||
)
|
||||
.menu(|window, cx| {
|
||||
let screens = cx.screen_capture_sources();
|
||||
Some(ContextMenu::build(window, cx, |context_menu, _, cx| {
|
||||
cx.spawn(async move |this: WeakEntity<ContextMenu>, cx| {
|
||||
let screens = screens.await??;
|
||||
this.update(cx, |this, cx| {
|
||||
let active_screenshare_id = ActiveCall::global(cx)
|
||||
.read(cx)
|
||||
.room()
|
||||
.and_then(|room| room.read(cx).shared_screen_id());
|
||||
for screen in screens {
|
||||
let Ok(meta) = screen.metadata() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let label = meta
|
||||
.label
|
||||
.clone()
|
||||
.unwrap_or_else(|| SharedString::from("Unknown screen"));
|
||||
let resolution = SharedString::from(format!(
|
||||
"{} × {}",
|
||||
meta.resolution.width.0, meta.resolution.height.0
|
||||
));
|
||||
this.push_item(ContextMenuItem::CustomEntry {
|
||||
entry_render: Box::new(move |_, _| {
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
Icon::new(IconName::Screen)
|
||||
.size(IconSize::XSmall)
|
||||
.map(|this| {
|
||||
if active_screenshare_id == Some(meta.id) {
|
||||
this.color(Color::Accent)
|
||||
} else {
|
||||
this.color(Color::Muted)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.child(Label::new(label.clone()))
|
||||
.child(
|
||||
Label::new(resolution.clone())
|
||||
.color(Color::Muted)
|
||||
.size(LabelSize::Small),
|
||||
)
|
||||
.into_any()
|
||||
}),
|
||||
selectable: true,
|
||||
documentation_aside: None,
|
||||
handler: Rc::new(move |_, window, cx| {
|
||||
toggle_screen_sharing(Ok(Some(screen.clone())), window, cx);
|
||||
}),
|
||||
});
|
||||
}
|
||||
})
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
context_menu
|
||||
}))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Picks the screen to share when clicking on the main screen sharing button.
|
||||
fn pick_default_screen(cx: &App) -> Task<anyhow::Result<Option<Rc<dyn ScreenCaptureSource>>>> {
|
||||
let source = cx.screen_capture_sources();
|
||||
cx.spawn(async move |_| {
|
||||
let available_sources = source.await??;
|
||||
Ok(available_sources
|
||||
.iter()
|
||||
.find(|it| {
|
||||
it.as_ref()
|
||||
.metadata()
|
||||
.is_ok_and(|meta| meta.is_main.unwrap_or_default())
|
||||
})
|
||||
.or_else(|| available_sources.first())
|
||||
.cloned())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
// This module provides infrastructure for showing onboarding banners in the title bar.
|
||||
// It's currently not in use but is kept for future feature announcements.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use gpui::{Action, Entity, Global, Render, SharedString, TaskExt};
|
||||
use ui::{ButtonLike, Tooltip, prelude::*};
|
||||
use util::ResultExt;
|
||||
|
||||
/// Prompts the user to try newly released Zed's features
|
||||
pub struct OnboardingBanner {
|
||||
dismissed: bool,
|
||||
source: String,
|
||||
details: BannerDetails,
|
||||
visible_when: Option<Box<dyn Fn(&mut App) -> bool>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct BannerGlobal {
|
||||
entity: Entity<OnboardingBanner>,
|
||||
}
|
||||
impl Global for BannerGlobal {}
|
||||
|
||||
pub struct BannerDetails {
|
||||
pub action: Box<dyn Action>,
|
||||
pub icon_name: IconName,
|
||||
pub label: SharedString,
|
||||
pub subtitle: Option<SharedString>,
|
||||
}
|
||||
|
||||
impl OnboardingBanner {
|
||||
pub fn new(
|
||||
source: &str,
|
||||
icon_name: IconName,
|
||||
label: impl Into<SharedString>,
|
||||
subtitle: Option<SharedString>,
|
||||
action: Box<dyn Action>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
cx.set_global(BannerGlobal {
|
||||
entity: cx.entity(),
|
||||
});
|
||||
Self {
|
||||
source: source.to_string(),
|
||||
details: BannerDetails {
|
||||
action,
|
||||
icon_name,
|
||||
label: label.into(),
|
||||
subtitle: subtitle.or(Some(SharedString::from("Introducing:"))),
|
||||
},
|
||||
visible_when: None,
|
||||
dismissed: get_dismissed(source, cx),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn visible_when(mut self, predicate: impl Fn(&mut App) -> bool + 'static) -> Self {
|
||||
self.visible_when = Some(Box::new(predicate));
|
||||
self
|
||||
}
|
||||
|
||||
fn should_show(&self, cx: &mut App) -> bool {
|
||||
!self.dismissed && self.visible_when.as_ref().map_or(true, |f| f(cx))
|
||||
}
|
||||
|
||||
fn dismiss(&mut self, cx: &mut Context<Self>) {
|
||||
persist_dismissed(&self.source, cx);
|
||||
self.dismissed = true;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn dismissed_at_key(source: &str) -> String {
|
||||
if source == "Git Onboarding" {
|
||||
"zed_git_banner_dismissed_at".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"{}_banner_dismissed_at",
|
||||
source.to_lowercase().trim().replace(" ", "_")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_dismissed(source: &str, cx: &App) -> bool {
|
||||
let dismissed_at = dismissed_at_key(source);
|
||||
db::kvp::KeyValueStore::global(cx)
|
||||
.read_kvp(&dismissed_at)
|
||||
.log_err()
|
||||
.is_some_and(|dismissed| dismissed.is_some())
|
||||
}
|
||||
|
||||
fn persist_dismissed(source: &str, cx: &mut App) {
|
||||
let dismissed_at = dismissed_at_key(source);
|
||||
let kvp = db::kvp::KeyValueStore::global(cx);
|
||||
cx.spawn(async move |_| {
|
||||
let time = chrono::Utc::now().to_rfc3339();
|
||||
kvp.write_kvp(dismissed_at, time).await
|
||||
})
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
|
||||
pub fn restore_banner(cx: &mut App) {
|
||||
if let Some(banner_global) = cx.try_global::<BannerGlobal>() {
|
||||
let entity = banner_global.entity.clone();
|
||||
cx.defer(move |cx| {
|
||||
entity.update(cx, |this, cx| {
|
||||
this.dismissed = false;
|
||||
cx.notify();
|
||||
});
|
||||
});
|
||||
|
||||
let source = &cx.global::<BannerGlobal>().entity.read(cx).source;
|
||||
let dismissed_at = dismissed_at_key(source);
|
||||
let kvp = db::kvp::KeyValueStore::global(cx);
|
||||
cx.spawn(async move |_| kvp.delete_kvp(dismissed_at).await)
|
||||
.detach_and_log_err(cx);
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for OnboardingBanner {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
if !self.should_show(cx) {
|
||||
return div();
|
||||
}
|
||||
|
||||
let border_color = cx.theme().colors().editor_foreground.opacity(0.3);
|
||||
let banner = h_flex()
|
||||
.rounded_sm()
|
||||
.border_1()
|
||||
.border_color(border_color)
|
||||
.occlude()
|
||||
.child(
|
||||
ButtonLike::new("try-a-feature")
|
||||
.child(
|
||||
h_flex()
|
||||
.h_full()
|
||||
.gap_1()
|
||||
.child(Icon::new(self.details.icon_name).size(IconSize::XSmall))
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_0p5()
|
||||
.when_some(self.details.subtitle.as_ref(), |this, subtitle| {
|
||||
this.child(
|
||||
Label::new(subtitle)
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
})
|
||||
.child(Label::new(&self.details.label).size(LabelSize::Small)),
|
||||
),
|
||||
)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
telemetry::event!("Banner Clicked", source = this.source);
|
||||
this.dismiss(cx);
|
||||
window.dispatch_action(this.details.action.boxed_clone(), cx)
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
div().border_l_1().border_color(border_color).child(
|
||||
IconButton::new("close", IconName::Close)
|
||||
.icon_size(IconSize::Indicator)
|
||||
.on_click(cx.listener(|this, _, _window, cx| {
|
||||
telemetry::event!("Banner Dismissed", source = this.source);
|
||||
this.dismiss(cx)
|
||||
}))
|
||||
.tooltip(|_window, cx| {
|
||||
Tooltip::with_meta(
|
||||
"Close Announcement Banner",
|
||||
None,
|
||||
"It won't show again for this feature",
|
||||
cx,
|
||||
)
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
div().pr_2().child(banner)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use cloud_api_types::Plan;
|
||||
use ui::{Chip, prelude::*};
|
||||
|
||||
/// A [`Chip`] that displays a [`Plan`].
|
||||
#[derive(IntoElement)]
|
||||
pub struct PlanChip {
|
||||
plan: Plan,
|
||||
}
|
||||
|
||||
impl PlanChip {
|
||||
pub fn new(plan: Plan) -> Self {
|
||||
Self { plan }
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for PlanChip {
|
||||
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let free_chip_bg = cx
|
||||
.theme()
|
||||
.colors()
|
||||
.editor_background
|
||||
.opacity(0.5)
|
||||
.blend(cx.theme().colors().text_accent.opacity(0.05));
|
||||
|
||||
let pro_chip_bg = cx
|
||||
.theme()
|
||||
.colors()
|
||||
.editor_background
|
||||
.opacity(0.5)
|
||||
.blend(cx.theme().colors().text_accent.opacity(0.2));
|
||||
|
||||
let (plan_name, label_color, bg_color) = match self.plan {
|
||||
Plan::ZedFree => ("Free", Color::Default, free_chip_bg),
|
||||
Plan::ZedProTrial => ("Pro Trial", Color::Accent, pro_chip_bg),
|
||||
Plan::ZedPro => ("Pro", Color::Accent, pro_chip_bg),
|
||||
Plan::ZedBusiness => ("Business", Color::Accent, pro_chip_bg),
|
||||
Plan::ZedStudent => ("Student", Color::Accent, pro_chip_bg),
|
||||
};
|
||||
|
||||
Chip::new(plan_name.to_string())
|
||||
.bg_color(bg_color)
|
||||
.label_color(label_color)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mod application_menu;
|
||||
|
||||
pub use application_menu::*;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,32 @@
|
||||
use gpui::WindowButtonLayout;
|
||||
use settings::{RegisterSetting, Settings, SettingsContent};
|
||||
|
||||
#[derive(Copy, Clone, Debug, RegisterSetting)]
|
||||
pub struct TitleBarSettings {
|
||||
pub show_branch_status_icon: bool,
|
||||
pub show_onboarding_banner: bool,
|
||||
pub show_user_picture: bool,
|
||||
pub show_branch_name: bool,
|
||||
pub show_project_items: bool,
|
||||
pub show_sign_in: bool,
|
||||
pub show_user_menu: bool,
|
||||
pub show_menus: bool,
|
||||
pub button_layout: Option<WindowButtonLayout>,
|
||||
}
|
||||
|
||||
impl Settings for TitleBarSettings {
|
||||
fn from_settings(s: &SettingsContent) -> Self {
|
||||
let content = s.title_bar.clone().unwrap();
|
||||
TitleBarSettings {
|
||||
show_branch_status_icon: content.show_branch_status_icon.unwrap(),
|
||||
show_onboarding_banner: content.show_onboarding_banner.unwrap(),
|
||||
show_user_picture: content.show_user_picture.unwrap(),
|
||||
show_branch_name: content.show_branch_name.unwrap(),
|
||||
show_project_items: content.show_project_items.unwrap(),
|
||||
show_sign_in: content.show_sign_in.unwrap(),
|
||||
show_user_menu: content.show_user_menu.unwrap(),
|
||||
show_menus: content.show_menus.unwrap(),
|
||||
button_layout: content.button_layout.unwrap_or_default().into_layout(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use auto_update::{AutoUpdateStatus, AutoUpdater, UpdateCheckType, VersionCheckType};
|
||||
use gpui::{Empty, Render};
|
||||
use semver::Version;
|
||||
use ui::{UpdateButton, prelude::*};
|
||||
|
||||
pub struct UpdateVersion {
|
||||
status: AutoUpdateStatus,
|
||||
update_check_type: UpdateCheckType,
|
||||
dismissed: bool,
|
||||
}
|
||||
|
||||
impl UpdateVersion {
|
||||
pub fn new(cx: &mut Context<Self>) -> Self {
|
||||
if let Some(auto_updater) = AutoUpdater::get(cx) {
|
||||
cx.observe(&auto_updater, |this, auto_update, cx| {
|
||||
this.status = auto_update.read(cx).status();
|
||||
this.update_check_type = auto_update.read(cx).update_check_type();
|
||||
if this.status.is_updated() {
|
||||
this.dismissed = false;
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
Self {
|
||||
status: auto_updater.read(cx).status(),
|
||||
update_check_type: UpdateCheckType::Automatic,
|
||||
dismissed: false,
|
||||
}
|
||||
} else {
|
||||
Self {
|
||||
status: AutoUpdateStatus::Idle,
|
||||
update_check_type: UpdateCheckType::Automatic,
|
||||
dismissed: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_simulation(&mut self, cx: &mut Context<Self>) {
|
||||
let next_state = match self.status {
|
||||
AutoUpdateStatus::Idle => AutoUpdateStatus::Checking,
|
||||
AutoUpdateStatus::Checking => AutoUpdateStatus::Downloading {
|
||||
version: VersionCheckType::Semantic(Version::new(1, 99, 0)),
|
||||
},
|
||||
AutoUpdateStatus::Downloading { .. } => AutoUpdateStatus::Installing {
|
||||
version: VersionCheckType::Semantic(Version::new(1, 99, 0)),
|
||||
},
|
||||
AutoUpdateStatus::Installing { .. } => AutoUpdateStatus::Updated {
|
||||
version: VersionCheckType::Semantic(Version::new(1, 99, 0)),
|
||||
},
|
||||
AutoUpdateStatus::Updated { .. } => AutoUpdateStatus::Errored {
|
||||
error: Arc::new(anyhow!("Network timeout")),
|
||||
},
|
||||
AutoUpdateStatus::Errored { .. } => AutoUpdateStatus::Idle,
|
||||
};
|
||||
|
||||
self.status = next_state;
|
||||
self.update_check_type = UpdateCheckType::Manual;
|
||||
self.dismissed = false;
|
||||
cx.notify()
|
||||
}
|
||||
|
||||
pub fn show_update_in_menu_bar(&self) -> bool {
|
||||
self.dismissed && self.status.is_updated()
|
||||
}
|
||||
|
||||
fn version_tooltip_message(version: &VersionCheckType) -> String {
|
||||
format!("Version: {}", {
|
||||
match version {
|
||||
VersionCheckType::Sha(sha) => format!("{}…", sha.short()),
|
||||
VersionCheckType::Semantic(semantic_version) => semantic_version.to_string(),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for UpdateVersion {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
if self.dismissed {
|
||||
return Empty.into_any_element();
|
||||
}
|
||||
match &self.status {
|
||||
AutoUpdateStatus::Checking if self.update_check_type.is_manual() => {
|
||||
UpdateButton::checking().into_any_element()
|
||||
}
|
||||
AutoUpdateStatus::Downloading { version } => {
|
||||
let tooltip = Self::version_tooltip_message(&version);
|
||||
UpdateButton::downloading(tooltip).into_any_element()
|
||||
}
|
||||
AutoUpdateStatus::Installing { version } => {
|
||||
let tooltip = Self::version_tooltip_message(&version);
|
||||
UpdateButton::installing(tooltip).into_any_element()
|
||||
}
|
||||
AutoUpdateStatus::Updated { version } => {
|
||||
let tooltip = Self::version_tooltip_message(&version);
|
||||
UpdateButton::updated(tooltip)
|
||||
.on_click(|_, _, cx| {
|
||||
workspace::reload(cx);
|
||||
})
|
||||
.on_dismiss(cx.listener(|this, _, _window, cx| {
|
||||
this.dismissed = true;
|
||||
cx.notify()
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
AutoUpdateStatus::Errored { error } => {
|
||||
let error_str = error.to_string();
|
||||
UpdateButton::errored(error_str)
|
||||
.on_click(|_, window, cx| {
|
||||
window.dispatch_action(Box::new(workspace::OpenLog), cx);
|
||||
})
|
||||
.on_dismiss(cx.listener(|this, _, _window, cx| {
|
||||
this.dismissed = true;
|
||||
cx.notify()
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
AutoUpdateStatus::Idle | AutoUpdateStatus::Checking { .. } => Empty.into_any_element(),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use auto_update::VersionCheckType;
|
||||
use release_channel::AppCommitSha;
|
||||
use semver::Version;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_version_tooltip_message() {
|
||||
let message = UpdateVersion::version_tooltip_message(&VersionCheckType::Semantic(
|
||||
Version::new(1, 0, 0),
|
||||
));
|
||||
|
||||
assert_eq!(message, "Version: 1.0.0");
|
||||
|
||||
let message = UpdateVersion::version_tooltip_message(&VersionCheckType::Sha(
|
||||
AppCommitSha::new("14d9a4189f058d8736339b06ff2340101eaea5af".to_string()),
|
||||
));
|
||||
|
||||
assert_eq!(message, "Version: 14d9a41…");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user