git_ui: Show uncommitted change count badge on git panel icon (#49624)

ISHIMWE Vainqueur and Danilo Leal created

## Summary

- Implements `icon_label` on `GitPanel` to return the total count of
uncommitted changes (`new_count + changes_count`) when non-zero, capped
at `"99+"` for large repos.
- Updates `PanelButtons::render()` to render that label as a small green
badge overlaid on the panel's sidebar icon, using absolute positioning
within a `div().relative()` wrapper.
- The badge uses `version_control_added` theme color and
`LabelSize::XSmall` text with `LineHeightStyle::UiLabel` for accurate
vertical centering, positioned at the top-right corner of the icon
button.

The `icon_label` method already existed on the `Panel`/`PanelHandle`
traits with a default `None` impl, and was already implemented by
`NotificationPanel` (unread notification count) and `TerminalPanel`
(open terminal count) — but was never rendered. This wires it up for all
three panels at once.

## Notes

- Badge is positioned with non-negative offsets (`top(0)`, `right(0)`)
to stay within the parent container's bounds. The status bar's
`render_left_tools()` uses `.overflow_x_hidden()`, which in GPUI clips
both axes (the `overflow_mask` returns a full content mask whenever any
axis is non-`Visible`), so negative offsets would be clipped.
- `LineHeightStyle::UiLabel` collapses line height to `relative(1.)` so
flex centering aligns the visual glyph rather than a
taller-than-necessary line box.
- No new data tracking logic — `GitPanel` already maintains `new_count`
and `changes_count` reactively.
- No feature flag or settings added per YAGNI.

## Suggested .rules additions

The following pattern came up repeatedly and would prevent future
sessions from hitting the same issue:

```
## GPUI overflow clipping

`overflow_x_hidden()` (and any single-axis overflow setter) clips **both** axes in GPUI.
The `overflow_mask()` implementation in `style.rs` returns a full `ContentMask` (bounding box)
whenever any axis is non-`Visible`. Absolute-positioned children that extend outside the element
bounds will be clipped even if only the X axis is set to Hidden.
Avoid negative `top`/`right`/`bottom`/`left` offsets on absolute children of containers
that have any overflow hidden — keep badge/overlay elements within the parent's bounds instead.
```

Release Notes:

- Added a numeric badge to the git panel sidebar icon showing the count
of uncommitted changes.

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>

Change summary

assets/settings/default.json                    |  8 +
crates/collab_ui/src/notification_panel.rs      |  3 
crates/collab_ui/src/panel_settings.rs          |  2 
crates/git_ui/src/git_panel.rs                  |  8 +
crates/git_ui/src/git_panel_settings.rs         |  2 
crates/settings/src/vscode_import.rs            |  1 
crates/settings_content/src/settings_content.rs |  9 +
crates/settings_content/src/terminal.rs         |  4 
crates/settings_ui/src/page_data.rs             | 72 ++++++++++++++
crates/terminal/src/terminal_settings.rs        |  2 
crates/terminal_view/src/terminal_panel.rs      |  3 
crates/ui/src/components.rs                     |  2 
crates/ui/src/components/count_badge.rs         | 93 +++++++++++++++++++
crates/workspace/src/dock.rs                    | 19 +++
crates/workspace/src/workspace.rs               |  2 
15 files changed, 221 insertions(+), 9 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -922,6 +922,10 @@
     ///
     /// Default: false
     "tree_view": false,
+    // Whether to show a badge on the git panel icon with the count of uncommitted changes.
+    //
+    // Default: false
+    "show_count_badge": false,
     "scrollbar": {
       // When to show the scrollbar in the git panel.
       //
@@ -946,6 +950,8 @@
     "dock": "right",
     // Default width of the notification panel.
     "default_width": 380,
+    // Whether to show a badge on the notification panel icon with the count of unread notifications.
+    "show_count_badge": false,
   },
   "agent": {
     // Whether the inline assistant should use streaming tools, when available
@@ -1867,6 +1873,8 @@
     // Timeout for hover and Cmd-click path hyperlink discovery in milliseconds. Specifying a
     // timeout of `0` will disable path hyperlinking in terminal.
     "path_hyperlink_timeout_ms": 1,
+    // Whether to show a badge on the terminal panel icon with the count of open terminals.
+    "show_count_badge": false,
   },
   "code_actions_on_format": {},
   // Settings related to running tasks.

crates/collab_ui/src/notification_panel.rs 🔗

@@ -677,6 +677,9 @@ impl Panel for NotificationPanel {
     }
 
     fn icon_label(&self, _window: &Window, cx: &App) -> Option<String> {
+        if !NotificationPanelSettings::get_global(cx).show_count_badge {
+            return None;
+        }
         let count = self.notification_store.read(cx).unread_notification_count();
         if count == 0 {
             None

crates/collab_ui/src/panel_settings.rs 🔗

@@ -15,6 +15,7 @@ pub struct NotificationPanelSettings {
     pub button: bool,
     pub dock: DockPosition,
     pub default_width: Pixels,
+    pub show_count_badge: bool,
 }
 
 impl Settings for CollaborationPanelSettings {
@@ -36,6 +37,7 @@ impl Settings for NotificationPanelSettings {
             button: panel.button.unwrap(),
             dock: panel.dock.unwrap().into(),
             default_width: panel.default_width.map(px).unwrap(),
+            show_count_badge: panel.show_count_badge.unwrap(),
         };
     }
 }

crates/git_ui/src/git_panel.rs 🔗

@@ -5797,6 +5797,14 @@ impl Panel for GitPanel {
         Some("Git Panel")
     }
 
+    fn icon_label(&self, _: &Window, cx: &App) -> Option<String> {
+        if !GitPanelSettings::get_global(cx).show_count_badge {
+            return None;
+        }
+        let total = self.changes_count;
+        (total > 0).then(|| total.to_string())
+    }
+
     fn toggle_action(&self) -> Box<dyn Action> {
         Box::new(ToggleFocus)
     }

crates/git_ui/src/git_panel_settings.rs 🔗

@@ -28,6 +28,7 @@ pub struct GitPanelSettings {
     pub collapse_untracked_diff: bool,
     pub tree_view: bool,
     pub diff_stats: bool,
+    pub show_count_badge: bool,
 }
 
 impl ScrollbarVisibility for GitPanelSettings {
@@ -64,6 +65,7 @@ impl Settings for GitPanelSettings {
             collapse_untracked_diff: git_panel.collapse_untracked_diff.unwrap(),
             tree_view: git_panel.tree_view.unwrap(),
             diff_stats: git_panel.diff_stats.unwrap(),
+            show_count_badge: git_panel.show_count_badge.unwrap(),
         }
     }
 }

crates/settings_content/src/settings_content.rs 🔗

@@ -635,6 +635,11 @@ pub struct GitPanelSettingsContent {
     ///
     /// Default: true
     pub diff_stats: Option<bool>,
+
+    /// Whether to show a badge on the git panel icon with the count of uncommitted changes.
+    ///
+    /// Default: false
+    pub show_count_badge: Option<bool>,
 }
 
 #[derive(
@@ -682,6 +687,10 @@ pub struct NotificationPanelSettingsContent {
     /// Default: 300
     #[serde(serialize_with = "crate::serialize_optional_f32_with_two_decimal_places")]
     pub default_width: Option<f32>,
+    /// Whether to show a badge on the notification panel icon with the count of unread notifications.
+    ///
+    /// Default: false
+    pub show_count_badge: Option<bool>,
 }
 
 #[with_fallible_options]

crates/settings_content/src/terminal.rs 🔗

@@ -171,6 +171,10 @@ pub struct TerminalSettingsContent {
     /// Default: 45
     #[serde(serialize_with = "crate::serialize_optional_f32_with_two_decimal_places")]
     pub minimum_contrast: Option<f32>,
+    /// Whether to show a badge on the terminal panel icon with the count of open terminals.
+    ///
+    /// Default: false
+    pub show_count_badge: Option<bool>,
 }
 
 /// Shell configuration to open the terminal with.

crates/settings_ui/src/page_data.rs 🔗

@@ -4820,7 +4820,7 @@ fn panels_page() -> SettingsPage {
         ]
     }
 
-    fn terminal_panel_section() -> [SettingsPageItem; 2] {
+    fn terminal_panel_section() -> [SettingsPageItem; 3] {
         [
             SettingsPageItem::SectionHeader("Terminal Panel"),
             SettingsPageItem::SettingItem(SettingItem {
@@ -4836,6 +4836,28 @@ fn panels_page() -> SettingsPage {
                 metadata: None,
                 files: USER,
             }),
+            SettingsPageItem::SettingItem(SettingItem {
+                title: "Show Count Badge",
+                description: "Show a badge on the terminal panel icon with the count of open terminals.",
+                field: Box::new(SettingField {
+                    json_path: Some("terminal.show_count_badge"),
+                    pick: |settings_content| {
+                        settings_content
+                            .terminal
+                            .as_ref()?
+                            .show_count_badge
+                            .as_ref()
+                    },
+                    write: |settings_content, value| {
+                        settings_content
+                            .terminal
+                            .get_or_insert_default()
+                            .show_count_badge = value;
+                    },
+                }),
+                metadata: None,
+                files: USER,
+            }),
         ]
     }
 
@@ -5048,7 +5070,7 @@ fn panels_page() -> SettingsPage {
         ]
     }
 
-    fn git_panel_section() -> [SettingsPageItem; 13] {
+    fn git_panel_section() -> [SettingsPageItem; 14] {
         [
             SettingsPageItem::SectionHeader("Git Panel"),
             SettingsPageItem::SettingItem(SettingItem {
@@ -5244,6 +5266,28 @@ fn panels_page() -> SettingsPage {
                 metadata: None,
                 files: USER,
             }),
+            SettingsPageItem::SettingItem(SettingItem {
+                title: "Show Count Badge",
+                description: "Whether to show a badge on the git panel icon with the count of uncommitted changes.",
+                field: Box::new(SettingField {
+                    json_path: Some("git_panel.show_count_badge"),
+                    pick: |settings_content| {
+                        settings_content
+                            .git_panel
+                            .as_ref()?
+                            .show_count_badge
+                            .as_ref()
+                    },
+                    write: |settings_content, value| {
+                        settings_content
+                            .git_panel
+                            .get_or_insert_default()
+                            .show_count_badge = value;
+                    },
+                }),
+                metadata: None,
+                files: USER,
+            }),
             SettingsPageItem::SettingItem(SettingItem {
                 title: "Scroll Bar",
                 description: "How and when the scrollbar should be displayed.",
@@ -5294,7 +5338,7 @@ fn panels_page() -> SettingsPage {
         ]
     }
 
-    fn notification_panel_section() -> [SettingsPageItem; 4] {
+    fn notification_panel_section() -> [SettingsPageItem; 5] {
         [
             SettingsPageItem::SectionHeader("Notification Panel"),
             SettingsPageItem::SettingItem(SettingItem {
@@ -5359,6 +5403,28 @@ fn panels_page() -> SettingsPage {
                 metadata: None,
                 files: USER,
             }),
+            SettingsPageItem::SettingItem(SettingItem {
+                title: "Show Count Badge",
+                description: "Show a badge on the notification panel icon with the count of unread notifications.",
+                field: Box::new(SettingField {
+                    json_path: Some("notification_panel.show_count_badge"),
+                    pick: |settings_content| {
+                        settings_content
+                            .notification_panel
+                            .as_ref()?
+                            .show_count_badge
+                            .as_ref()
+                    },
+                    write: |settings_content, value| {
+                        settings_content
+                            .notification_panel
+                            .get_or_insert_default()
+                            .show_count_badge = value;
+                    },
+                }),
+                metadata: None,
+                files: USER,
+            }),
         ]
     }
 

crates/terminal/src/terminal_settings.rs 🔗

@@ -50,6 +50,7 @@ pub struct TerminalSettings {
     pub minimum_contrast: f32,
     pub path_hyperlink_regexes: Vec<String>,
     pub path_hyperlink_timeout_ms: u64,
+    pub show_count_badge: bool,
 }
 
 #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
@@ -129,6 +130,7 @@ impl settings::Settings for TerminalSettings {
                 })
                 .collect(),
             path_hyperlink_timeout_ms: project_content.path_hyperlink_timeout_ms.unwrap(),
+            show_count_badge: user_content.show_count_badge.unwrap(),
         }
     }
 }

crates/terminal_view/src/terminal_panel.rs 🔗

@@ -1606,6 +1606,9 @@ impl Panel for TerminalPanel {
     }
 
     fn icon_label(&self, _window: &Window, cx: &App) -> Option<String> {
+        if !TerminalSettings::get_global(cx).show_count_badge {
+            return None;
+        }
         let count = self
             .center
             .panes()

crates/ui/src/components.rs 🔗

@@ -6,6 +6,7 @@ mod callout;
 mod chip;
 mod collab;
 mod context_menu;
+mod count_badge;
 mod data_table;
 mod diff_stat;
 mod disclosure;
@@ -49,6 +50,7 @@ pub use callout::*;
 pub use chip::*;
 pub use collab::*;
 pub use context_menu::*;
+pub use count_badge::*;
 pub use data_table::*;
 pub use diff_stat::*;
 pub use disclosure::*;

crates/ui/src/components/count_badge.rs 🔗

@@ -0,0 +1,93 @@
+use gpui::FontWeight;
+
+use crate::prelude::*;
+
+/// A small, pill-shaped badge that displays a numeric count.
+///
+/// The count is capped at 99 and displayed as "99+" beyond that.
+#[derive(IntoElement, RegisterComponent)]
+pub struct CountBadge {
+    count: usize,
+}
+
+impl CountBadge {
+    pub fn new(count: usize) -> Self {
+        Self { count }
+    }
+}
+
+impl RenderOnce for CountBadge {
+    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
+        let label = if self.count > 99 {
+            "99+".to_string()
+        } else {
+            self.count.to_string()
+        };
+
+        let bg = cx
+            .theme()
+            .colors()
+            .editor_background
+            .blend(cx.theme().status().error.opacity(0.4));
+
+        h_flex()
+            .absolute()
+            .top_0()
+            .right_0()
+            .p_px()
+            .h_3p5()
+            .min_w_3p5()
+            .rounded_full()
+            .justify_center()
+            .text_center()
+            .border_1()
+            .border_color(cx.theme().colors().border)
+            .bg(bg)
+            .shadow_sm()
+            .child(
+                Label::new(label)
+                    .size(LabelSize::Custom(rems_from_px(9.)))
+                    .weight(FontWeight::MEDIUM),
+            )
+    }
+}
+
+impl Component for CountBadge {
+    fn scope() -> ComponentScope {
+        ComponentScope::Status
+    }
+
+    fn description() -> Option<&'static str> {
+        Some("A small, pill-shaped badge that displays a numeric count.")
+    }
+
+    fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
+        let container = || {
+            div()
+                .relative()
+                .size_8()
+                .border_1()
+                .border_color(cx.theme().colors().border)
+                .bg(cx.theme().colors().background)
+        };
+
+        Some(
+            v_flex()
+                .gap_6()
+                .child(example_group_with_title(
+                    "Count Badge",
+                    vec![
+                        single_example(
+                            "Basic Count",
+                            container().child(CountBadge::new(3)).into_any_element(),
+                        ),
+                        single_example(
+                            "Capped Count",
+                            container().child(CountBadge::new(150)).into_any_element(),
+                        ),
+                    ],
+                ))
+                .into_any_element(),
+        )
+    }
+}

crates/workspace/src/dock.rs 🔗

@@ -12,8 +12,10 @@ use gpui::{
 };
 use settings::SettingsStore;
 use std::sync::Arc;
-use ui::{ContextMenu, Divider, DividerColor, IconButton, Tooltip, h_flex};
-use ui::{prelude::*, right_click_menu};
+use ui::{
+    ContextMenu, CountBadge, Divider, DividerColor, IconButton, Tooltip, prelude::*,
+    right_click_menu,
+};
 use util::ResultExt as _;
 
 pub(crate) const RESIZE_HANDLE_SIZE: Pixels = px(6.);
@@ -940,6 +942,7 @@ impl Render for PanelButtons {
                 };
 
                 let focus_handle = dock.focus_handle(cx);
+                let icon_label = entry.panel.icon_label(window, cx);
 
                 Some(
                     right_click_menu(name)
@@ -973,7 +976,7 @@ impl Render for PanelButtons {
                         .trigger(move |is_active, _window, _cx| {
                             // Include active state in element ID to invalidate the cached
                             // tooltip when panel state changes (e.g., via keyboard shortcut)
-                            IconButton::new((name, is_active_button as u64), icon)
+                            let button = IconButton::new((name, is_active_button as u64), icon)
                                 .icon_size(IconSize::Small)
                                 .toggle_state(is_active_button)
                                 .on_click({
@@ -987,7 +990,15 @@ impl Render for PanelButtons {
                                     this.tooltip(move |_window, cx| {
                                         Tooltip::for_action(tooltip.clone(), &*action, cx)
                                     })
-                                })
+                                });
+
+                            div().relative().child(button).when_some(
+                                icon_label
+                                    .clone()
+                                    .filter(|_| !is_active_button)
+                                    .and_then(|label| label.parse::<usize>().ok()),
+                                |this, count| this.child(CountBadge::new(count)),
+                            )
                         }),
                 )
             })

crates/workspace/src/workspace.rs 🔗

@@ -7868,7 +7868,6 @@ impl Render for Workspace {
                                                 window,
                                                 cx,
                                             )),
-
                                         BottomDockLayout::RightAligned => div()
                                             .flex()
                                             .flex_row()
@@ -7927,7 +7926,6 @@ impl Render for Workspace {
                                                             .children(self.render_dock(DockPosition::Bottom, &self.bottom_dock, window, cx))
                                                     ),
                                             ),
-
                                         BottomDockLayout::Contained => div()
                                             .flex()
                                             .flex_row()