diff --git a/crates/ely_app/src/shell/chrome/sidebar.rs b/crates/ely_app/src/shell/chrome/sidebar.rs index f2718b8..901df86 100644 --- a/crates/ely_app/src/shell/chrome/sidebar.rs +++ b/crates/ely_app/src/shell/chrome/sidebar.rs @@ -220,6 +220,7 @@ impl ElyShell { let host = tab.url().host().map(|host| host.to_string()); let title = tab.title().to_string(); let initial = title.chars().next().unwrap_or('?').to_string(); + let unread = tab.unread_count(); let group_name = SharedString::from(format!("launcher-{}", tab.id().as_str())); let close_id = SharedString::from(format!("launcher-close-{}", tab.id().as_str())); @@ -252,6 +253,7 @@ impl ElyShell { .text_color(rgb(text_color)) .child(title), ) + .when(unread > 0, |el| el.child(render_unread_badge(unread))) .child( div() .id(close_id) @@ -354,6 +356,24 @@ fn profile_initial(name: &str) -> String { .to_string() } +fn render_unread_badge(count: u32) -> impl IntoElement { + let label = if count > 99 { + "99+".to_string() + } else { + count.to_string() + }; + + div() + .px(px(6.0)) + .py(px(1.0)) + .rounded(px(999.0)) + .bg(rgba(UNREAD_BADGE_BG)) + .text_size(px(10.0)) + .font_weight(gpui::FontWeight(500.0)) + .text_color(rgb(colors::INK_3)) + .child(label) +} + fn section_tabs_label(count: usize) -> impl IntoElement { div() .pt(px(12.0)) @@ -389,6 +409,7 @@ fn section_label(label: &'static str) -> impl IntoElement { pub(crate) const ACTIVE_NAV_BG: u32 = 0xffffffd9; pub(crate) const PANEL_BG: u32 = 0xffffffe0; +const UNREAD_BADGE_BG: u32 = 0x281e140f; pub(crate) fn panel_shadow() -> Vec { vec![ diff --git a/crates/ely_domain/src/lib.rs b/crates/ely_domain/src/lib.rs index 9c9c2fd..c403fc1 100644 --- a/crates/ely_domain/src/lib.rs +++ b/crates/ely_domain/src/lib.rs @@ -64,7 +64,7 @@ pub use sync::{ }; pub use tab::{ BrowserTab, DEFAULT_ZOOM_PERCENT, MAX_ZOOM_PERCENT, MIN_ZOOM_PERCENT, TabFlags, TabState, - ZOOM_PERCENT_STEP, validate_zoom_percent, + ZOOM_PERCENT_STEP, parse_title_unread_count, validate_zoom_percent, }; pub use tab_group::TabGroup; pub use update::UpdatePolicy; diff --git a/crates/ely_domain/src/tab.rs b/crates/ely_domain/src/tab.rs index 44a3b9e..d521a36 100644 --- a/crates/ely_domain/src/tab.rs +++ b/crates/ely_domain/src/tab.rs @@ -106,6 +106,11 @@ impl BrowserTab { &self.title } + #[must_use] + pub fn unread_count(&self) -> u32 { + parse_title_unread_count(&self.title) + } + #[must_use] pub fn url(&self) -> &UrlText { &self.url @@ -274,3 +279,46 @@ pub fn validate_zoom_percent(value: u16) -> Result { Err(DomainError::InvalidZoomPercent { value, min: MIN_ZOOM_PERCENT, max: MAX_ZOOM_PERCENT }) } + +#[must_use] +pub fn parse_title_unread_count(title: &str) -> u32 { + let trimmed = title.trim_start(); + let Some(rest) = trimmed.strip_prefix('(') else { + return 0; + }; + let Some(close) = rest.find(')') else { + return 0; + }; + rest[..close].parse::().unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::parse_title_unread_count; + + #[test] + fn parses_leading_parenthesised_count() { + assert_eq!(parse_title_unread_count("(12) Slack"), 12); + assert_eq!(parse_title_unread_count("(3) Inbox — Linear"), 3); + assert_eq!(parse_title_unread_count("(0) Quiet"), 0); + } + + #[test] + fn ignores_non_prefixed_titles() { + assert_eq!(parse_title_unread_count("Slack"), 0); + assert_eq!(parse_title_unread_count("Inbox (12)"), 0); + assert_eq!(parse_title_unread_count(""), 0); + } + + #[test] + fn rejects_non_numeric_or_overflow_counts() { + assert_eq!(parse_title_unread_count("(99+) Gmail"), 0); + assert_eq!(parse_title_unread_count("(abc) Mail"), 0); + assert_eq!(parse_title_unread_count("(99999999999999999999) huge"), 0); + } + + #[test] + fn tolerates_leading_whitespace() { + assert_eq!(parse_title_unread_count(" (7) Slack"), 7); + } +}