Show badge when there are pending contact requests

Antonio Scandurra and Nathan Sobo created

Co-Authored-By: Nathan Sobo <nathan@zed.dev>

Change summary

assets/themes/cave-dark.json                |  13 ++
assets/themes/cave-light.json               |  13 ++
assets/themes/dark.json                     |  13 ++
assets/themes/light.json                    |  13 ++
assets/themes/solarized-dark.json           |  13 ++
assets/themes/solarized-light.json          |  13 ++
assets/themes/sulphurpool-dark.json         |  13 ++
assets/themes/sulphurpool-light.json        |  13 ++
crates/collab/src/rpc.rs                    |   2 
crates/contacts_panel/src/contacts_panel.rs |  16 ++
crates/gpui/src/elements/empty.rs           |  15 ++
crates/project_panel/src/project_panel.rs   |   6 +
crates/theme/src/theme.rs                   |   1 
crates/workspace/src/sidebar.rs             | 120 +++++++++++++++++-----
crates/workspace/src/workspace.rs           |   4 
styles/src/styleTree/statusBar.ts           |   9 +
styles/src/styleTree/workspace.ts           |   6 
17 files changed, 241 insertions(+), 42 deletions(-)

Detailed changes

assets/themes/cave-dark.json 🔗

@@ -341,6 +341,19 @@
             "icon_color": "#efecf4",
             "background": "#5852605c"
           }
+        },
+        "badge": {
+          "corner_radius": 3,
+          "padding": 2,
+          "margin": {
+            "bottom": -1,
+            "right": -1
+          },
+          "border": {
+            "width": 1,
+            "color": "#26232a"
+          },
+          "background": "#576ddb"
         }
       }
     },

assets/themes/cave-light.json 🔗

@@ -341,6 +341,19 @@
             "icon_color": "#19171c",
             "background": "#8b87922e"
           }
+        },
+        "badge": {
+          "corner_radius": 3,
+          "padding": 2,
+          "margin": {
+            "bottom": -1,
+            "right": -1
+          },
+          "border": {
+            "width": 1,
+            "color": "#e2dfe7"
+          },
+          "background": "#576ddb"
         }
       }
     },

assets/themes/dark.json 🔗

@@ -341,6 +341,19 @@
             "icon_color": "#ffffff",
             "background": "#2b2b2b"
           }
+        },
+        "badge": {
+          "corner_radius": 3,
+          "padding": 2,
+          "margin": {
+            "bottom": -1,
+            "right": -1
+          },
+          "border": {
+            "width": 1,
+            "color": "#1c1c1c"
+          },
+          "background": "#2472f2"
         }
       }
     },

assets/themes/light.json 🔗

@@ -341,6 +341,19 @@
             "icon_color": "#000000",
             "background": "#e3e3e3"
           }
+        },
+        "badge": {
+          "corner_radius": 3,
+          "padding": 2,
+          "margin": {
+            "bottom": -1,
+            "right": -1
+          },
+          "border": {
+            "width": 1,
+            "color": "#f8f8f8"
+          },
+          "background": "#484bed"
         }
       }
     },

assets/themes/solarized-dark.json 🔗

@@ -341,6 +341,19 @@
             "icon_color": "#fdf6e3",
             "background": "#586e755c"
           }
+        },
+        "badge": {
+          "corner_radius": 3,
+          "padding": 2,
+          "margin": {
+            "bottom": -1,
+            "right": -1
+          },
+          "border": {
+            "width": 1,
+            "color": "#073642"
+          },
+          "background": "#268bd2"
         }
       }
     },

assets/themes/solarized-light.json 🔗

@@ -341,6 +341,19 @@
             "icon_color": "#002b36",
             "background": "#93a1a12e"
           }
+        },
+        "badge": {
+          "corner_radius": 3,
+          "padding": 2,
+          "margin": {
+            "bottom": -1,
+            "right": -1
+          },
+          "border": {
+            "width": 1,
+            "color": "#eee8d5"
+          },
+          "background": "#268bd2"
         }
       }
     },

assets/themes/sulphurpool-dark.json 🔗

@@ -341,6 +341,19 @@
             "icon_color": "#f5f7ff",
             "background": "#5e66875c"
           }
+        },
+        "badge": {
+          "corner_radius": 3,
+          "padding": 2,
+          "margin": {
+            "bottom": -1,
+            "right": -1
+          },
+          "border": {
+            "width": 1,
+            "color": "#293256"
+          },
+          "background": "#3d8fd1"
         }
       }
     },

assets/themes/sulphurpool-light.json 🔗

@@ -341,6 +341,19 @@
             "icon_color": "#202746",
             "background": "#979db42e"
           }
+        },
+        "badge": {
+          "corner_radius": 3,
+          "padding": 2,
+          "margin": {
+            "bottom": -1,
+            "right": -1
+          },
+          "border": {
+            "width": 1,
+            "color": "#dfe2f1"
+          },
+          "background": "#3d8fd1"
         }
       }
     },

crates/collab/src/rpc.rs 🔗

@@ -7264,7 +7264,7 @@ mod tests {
         }
 
         fn render(&mut self, _: &mut gpui::RenderContext<Self>) -> gpui::ElementBox {
-            gpui::Element::boxed(gpui::elements::Empty)
+            gpui::Element::boxed(gpui::elements::Empty::new())
         }
     }
 }

crates/contacts_panel/src/contacts_panel.rs 🔗

@@ -10,14 +10,14 @@ use gpui::{
     geometry::{rect::RectF, vector::vec2f},
     impl_actions,
     platform::CursorStyle,
-    Element, ElementBox, Entity, LayoutContext, ModelHandle, MutableAppContext, RenderContext,
-    Subscription, View, ViewContext, ViewHandle, WeakViewHandle,
+    AppContext, Element, ElementBox, Entity, LayoutContext, ModelHandle, MutableAppContext,
+    RenderContext, Subscription, View, ViewContext, ViewHandle, WeakViewHandle,
 };
 use serde::Deserialize;
 use settings::Settings;
 use std::sync::Arc;
 use theme::IconButton;
-use workspace::{AppState, JoinProject, Workspace};
+use workspace::{sidebar::SidebarItem, AppState, JoinProject, Workspace};
 
 impl_actions!(
     contacts_panel,
@@ -599,6 +599,16 @@ impl ContactsPanel {
     }
 }
 
+impl SidebarItem for ContactsPanel {
+    fn should_show_badge(&self, cx: &AppContext) -> bool {
+        !self
+            .user_store
+            .read(cx)
+            .incoming_contact_requests()
+            .is_empty()
+    }
+}
+
 fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element {
     Svg::new(svg_path)
         .with_color(style.color)

crates/gpui/src/elements/empty.rs 🔗

@@ -8,11 +8,18 @@ use crate::{
 };
 use crate::{Element, Event, EventContext, LayoutContext, PaintContext, SizeConstraint};
 
-pub struct Empty;
+pub struct Empty {
+    collapsed: bool,
+}
 
 impl Empty {
     pub fn new() -> Self {
-        Self
+        Self { collapsed: false }
+    }
+
+    pub fn collapsed(mut self) -> Self {
+        self.collapsed = true;
+        self
     }
 }
 
@@ -25,12 +32,12 @@ impl Element for Empty {
         constraint: SizeConstraint,
         _: &mut LayoutContext,
     ) -> (Vector2F, Self::LayoutState) {
-        let x = if constraint.max.x().is_finite() {
+        let x = if constraint.max.x().is_finite() && !self.collapsed {
             constraint.max.x()
         } else {
             constraint.min.x()
         };
-        let y = if constraint.max.y().is_finite() {
+        let y = if constraint.max.y().is_finite() && !self.collapsed {
             constraint.max.y()
         } else {
             constraint.min.y()

crates/project_panel/src/project_panel.rs 🔗

@@ -900,6 +900,12 @@ impl Entity for ProjectPanel {
     type Event = Event;
 }
 
+impl workspace::sidebar::SidebarItem for ProjectPanel {
+    fn should_show_badge(&self, _: &AppContext) -> bool {
+        false
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;

crates/theme/src/theme.rs 🔗

@@ -162,6 +162,7 @@ pub struct StatusBarSidebarButtons {
     pub group_left: ContainerStyle,
     pub group_right: ContainerStyle,
     pub item: Interactive<SidebarItem>,
+    pub badge: ContainerStyle,
 }
 
 #[derive(Deserialize, Default)]

crates/workspace/src/sidebar.rs 🔗

@@ -1,13 +1,40 @@
+use crate::StatusItemView;
 use gpui::{
-    elements::*, impl_actions, platform::CursorStyle, AnyViewHandle, Entity, RenderContext, View,
-    ViewContext, ViewHandle,
+    elements::*, impl_actions, platform::CursorStyle, AnyViewHandle, AppContext, Entity,
+    RenderContext, Subscription, View, ViewContext, ViewHandle,
 };
 use serde::Deserialize;
 use settings::Settings;
 use std::{cell::RefCell, rc::Rc};
 use theme::Theme;
 
-use crate::StatusItemView;
+pub trait SidebarItem: View {
+    fn should_show_badge(&self, cx: &AppContext) -> bool;
+}
+
+pub trait SidebarItemHandle {
+    fn should_show_badge(&self, cx: &AppContext) -> bool;
+    fn to_any(&self) -> AnyViewHandle;
+}
+
+impl<T> SidebarItemHandle for ViewHandle<T>
+where
+    T: SidebarItem,
+{
+    fn should_show_badge(&self, cx: &AppContext) -> bool {
+        self.read(cx).should_show_badge(cx)
+    }
+
+    fn to_any(&self) -> AnyViewHandle {
+        self.into()
+    }
+}
+
+impl Into<AnyViewHandle> for &dyn SidebarItemHandle {
+    fn into(self) -> AnyViewHandle {
+        self.to_any()
+    }
+}
 
 pub struct Sidebar {
     side: Side,
@@ -23,10 +50,10 @@ pub enum Side {
     Right,
 }
 
-#[derive(Clone)]
 struct Item {
     icon_path: &'static str,
-    view: AnyViewHandle,
+    view: Rc<dyn SidebarItemHandle>,
+    _observation: Subscription,
 }
 
 pub struct SidebarButtons {
@@ -58,13 +85,18 @@ impl Sidebar {
         }
     }
 
-    pub fn add_item(
+    pub fn add_item<T: SidebarItem>(
         &mut self,
         icon_path: &'static str,
-        view: AnyViewHandle,
+        view: ViewHandle<T>,
         cx: &mut ViewContext<Self>,
     ) {
-        self.items.push(Item { icon_path, view });
+        let subscription = cx.observe(&view, |_, _, cx| cx.notify());
+        self.items.push(Item {
+            icon_path,
+            view: Rc::new(view),
+            _observation: subscription,
+        });
         cx.notify()
     }
 
@@ -82,10 +114,10 @@ impl Sidebar {
         cx.notify();
     }
 
-    pub fn active_item(&self) -> Option<&AnyViewHandle> {
+    pub fn active_item(&self) -> Option<&dyn SidebarItemHandle> {
         self.active_item_ix
             .and_then(|ix| self.items.get(ix))
-            .map(|item| &item.view)
+            .map(|item| item.view.as_ref())
     }
 
     fn render_resize_handle(&self, theme: &Theme, cx: &mut RenderContext<Self>) -> ElementBox {
@@ -185,34 +217,62 @@ impl View for SidebarButtons {
             .sidebar_buttons;
         let sidebar = self.sidebar.read(cx);
         let item_style = theme.item;
+        let badge_style = theme.badge;
         let active_ix = sidebar.active_item_ix;
         let side = sidebar.side;
         let group_style = match side {
             Side::Left => theme.group_left,
             Side::Right => theme.group_right,
         };
-        let items = sidebar.items.clone();
+        let items = sidebar
+            .items
+            .iter()
+            .map(|item| (item.icon_path, item.view.clone()))
+            .collect::<Vec<_>>();
         Flex::row()
-            .with_children(items.iter().enumerate().map(|(ix, item)| {
-                MouseEventHandler::new::<Self, _, _>(ix, cx, move |state, _| {
-                    let style = item_style.style_for(state, Some(ix) == active_ix);
-                    Svg::new(item.icon_path)
-                        .with_color(style.icon_color)
-                        .constrained()
-                        .with_height(style.icon_size)
-                        .contained()
-                        .with_style(style.container)
+            .with_children(
+                items
+                    .into_iter()
+                    .enumerate()
+                    .map(|(ix, (icon_path, item_view))| {
+                        MouseEventHandler::new::<Self, _, _>(ix, cx, move |state, cx| {
+                            let is_active = Some(ix) == active_ix;
+                            let style = item_style.style_for(state, is_active);
+                            Stack::new()
+                                .with_child(
+                                    Svg::new(icon_path).with_color(style.icon_color).boxed(),
+                                )
+                                .with_children(if !is_active && item_view.should_show_badge(cx) {
+                                    Some(
+                                        Empty::new()
+                                            .collapsed()
+                                            .contained()
+                                            .with_style(badge_style)
+                                            .aligned()
+                                            .bottom()
+                                            .right()
+                                            .boxed(),
+                                    )
+                                } else {
+                                    None
+                                })
+                                .constrained()
+                                .with_width(style.icon_size)
+                                .with_height(style.icon_size)
+                                .contained()
+                                .with_style(style.container)
+                                .boxed()
+                        })
+                        .with_cursor_style(CursorStyle::PointingHand)
+                        .on_click(move |_, cx| {
+                            cx.dispatch_action(ToggleSidebarItem {
+                                side,
+                                item_index: ix,
+                            })
+                        })
                         .boxed()
-                })
-                .with_cursor_style(CursorStyle::PointingHand)
-                .on_click(move |_, cx| {
-                    cx.dispatch_action(ToggleSidebarItem {
-                        side,
-                        item_index: ix,
-                    })
-                })
-                .boxed()
-            }))
+                    }),
+            )
             .contained()
             .with_style(group_style)
             .boxed()

crates/workspace/src/workspace.rs 🔗

@@ -1102,7 +1102,7 @@ impl Workspace {
         };
         let active_item = sidebar.update(cx, |sidebar, cx| {
             sidebar.toggle_item(action.item_index, cx);
-            sidebar.active_item().cloned()
+            sidebar.active_item().map(|item| item.to_any())
         });
         if let Some(active_item) = active_item {
             cx.focus(active_item);
@@ -1123,7 +1123,7 @@ impl Workspace {
         };
         let active_item = sidebar.update(cx, |sidebar, cx| {
             sidebar.activate_item(action.item_index, cx);
-            sidebar.active_item().cloned()
+            sidebar.active_item().map(|item| item.to_any())
         });
         if let Some(active_item) = active_item {
             if active_item.is_focused(cx) {

styles/src/styleTree/statusBar.ts 🔗

@@ -1,8 +1,8 @@
 import Theme from "../themes/theme";
 import { backgroundColor, border, iconColor, text } from "./components";
+import { workspaceBackground } from "./workspace";
 
 export default function statusBar(theme: Theme) {
-
   const statusContainer = {
     cornerRadius: 6,
     padding: { top: 3, bottom: 3, left: 6, right: 6 }
@@ -100,6 +100,13 @@ export default function statusBar(theme: Theme) {
           iconColor: iconColor(theme, "active"),
           background: backgroundColor(theme, 300, "active"),
         }
+      },
+      badge: {
+        cornerRadius: 3,
+        padding: 2,
+        margin: { bottom: -1, right: -1 },
+        border: { width: 1, color: workspaceBackground(theme) },
+        background: iconColor(theme, "feature"),
       }
     }
   }

styles/src/styleTree/workspace.ts 🔗

@@ -2,11 +2,15 @@ import Theme from "../themes/theme";
 import { backgroundColor, border, iconColor, shadow, text } from "./components";
 import statusBar from "./statusBar";
 
+export function workspaceBackground(theme: Theme) {
+  return backgroundColor(theme, 300)
+}
+
 export default function workspace(theme: Theme) {
 
   const tab = {
     height: 32,
-    background: backgroundColor(theme, 300),
+    background: workspaceBackground(theme),
     iconClose: iconColor(theme, "muted"),
     iconCloseActive: iconColor(theme, "active"),
     iconConflict: iconColor(theme, "warning"),