git_ui: Start unifying panel style with other panels (#24296)

Nate Butler created

- Adds the `panel` crate for defining UI shared between panels, like
common button and header designs, etc
- Starts to update the git ui to be more consistent with other panels

Release Notes:

- N/A

Change summary

Cargo.lock                        | 10 +++
Cargo.toml                        |  5 +
crates/git_ui/Cargo.toml          |  5 +
crates/git_ui/src/git_panel.rs    | 62 ++++++++++++++---------
crates/panel/Cargo.toml           | 21 +++++++
crates/panel/LICENSE-GPL          |  1 
crates/panel/src/panel.rs         | 66 +++++++++++++++++++++++++
crates/workspace/src/workspace.rs |  3 
script/new-crate                  | 87 +++++++++++++++++++++++++++++++++
9 files changed, 230 insertions(+), 30 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -5245,6 +5245,7 @@ dependencies = [
  "language",
  "menu",
  "multi_buffer",
+ "panel",
  "picker",
  "postage",
  "project",
@@ -8821,6 +8822,15 @@ dependencies = [
  "syn 2.0.90",
 ]
 
+[[package]]
+name = "panel"
+version = "0.1.0"
+dependencies = [
+ "gpui",
+ "ui",
+ "workspace",
+]
+
 [[package]]
 name = "parity-tokio-ipc"
 version = "0.9.0"

Cargo.toml 🔗

@@ -87,6 +87,7 @@ members = [
     "crates/open_ai",
     "crates/outline",
     "crates/outline_panel",
+    "crates/panel",
     "crates/paths",
     "crates/picker",
     "crates/prettier",
@@ -103,7 +104,6 @@ members = [
     "crates/remote_server",
     "crates/repl",
     "crates/reqwest_client",
-    "crates/reqwest_client",
     "crates/rich_text",
     "crates/rope",
     "crates/rpc",
@@ -243,8 +243,8 @@ fs = { path = "crates/fs" }
 fsevent = { path = "crates/fsevent" }
 fuzzy = { path = "crates/fuzzy" }
 git = { path = "crates/git" }
-git_ui = { path = "crates/git_ui" }
 git_hosting_providers = { path = "crates/git_hosting_providers" }
+git_ui = { path = "crates/git_ui" }
 go_to_line = { path = "crates/go_to_line" }
 google_ai = { path = "crates/google_ai" }
 gpui = { path = "crates/gpui", default-features = false, features = [
@@ -285,6 +285,7 @@ open_ai = { path = "crates/open_ai" }
 outline = { path = "crates/outline" }
 outline_panel = { path = "crates/outline_panel" }
 paths = { path = "crates/paths" }
+panel = { path = "crates/panel" }
 picker = { path = "crates/picker" }
 plugin = { path = "crates/plugin" }
 plugin_macros = { path = "crates/plugin_macros" }

crates/git_ui/Cargo.toml 🔗

@@ -22,8 +22,10 @@ futures.workspace = true
 git.workspace = true
 gpui.workspace = true
 language.workspace = true
-multi_buffer.workspace = true
 menu.workspace = true
+multi_buffer.workspace = true
+panel.workspace = true
+picker.workspace = true
 postage.workspace = true
 project.workspace = true
 schemars.workspace = true
@@ -35,7 +37,6 @@ theme.workspace = true
 ui.workspace = true
 util.workspace = true
 workspace.workspace = true
-picker.workspace = true
 
 [target.'cfg(windows)'.dependencies]
 windows.workspace = true

crates/git_ui/src/git_panel.rs 🔗

@@ -16,6 +16,7 @@ use git::{CommitAllChanges, CommitChanges, ToggleStaged};
 use gpui::*;
 use language::Buffer;
 use menu::{SelectFirst, SelectLast, SelectNext, SelectPrev};
+use panel::PanelHeader;
 use project::git::{GitEvent, Repository};
 use project::{Fs, Project, ProjectPath};
 use serde::{Deserialize, Serialize};
@@ -1060,6 +1061,10 @@ impl GitPanel {
             .style(ButtonStyle::Filled)
     }
 
+    pub fn indent_size(&self, window: &Window, cx: &mut Context<Self>) -> Pixels {
+        Checkbox::container_size(cx).to_pixels(window.rem_size())
+    }
+
     pub fn render_divider(&self, _cx: &mut Context<Self>) -> impl IntoElement {
         h_flex()
             .items_center()
@@ -1069,7 +1074,7 @@ impl GitPanel {
 
     pub fn render_panel_header(
         &self,
-        _window: &mut Window,
+        window: &mut Window,
         cx: &mut Context<Self>,
     ) -> impl IntoElement {
         let all_repositories = self
@@ -1089,11 +1094,7 @@ impl GitPanel {
             n => format!("{} changes", n),
         };
 
-        h_flex()
-            .h(px(32.))
-            .items_center()
-            .px_2()
-            .bg(ElevationIndex::Surface.bg(cx))
+        self.panel_header_container(window, cx)
             .child(h_flex().gap_2().child(if all_repositories.len() <= 1 {
                 div()
                     .id("changes-label")
@@ -1304,7 +1305,12 @@ impl GitPanel {
         )
     }
 
-    fn render_entries(&self, has_write_access: bool, cx: &mut Context<Self>) -> impl IntoElement {
+    fn render_entries(
+        &self,
+        has_write_access: bool,
+        window: &Window,
+        cx: &mut Context<Self>,
+    ) -> impl IntoElement {
         let entry_count = self.entries.len();
 
         v_flex()
@@ -1312,19 +1318,26 @@ impl GitPanel {
             .overflow_hidden()
             .child(
                 uniform_list(cx.entity().clone(), "entries", entry_count, {
-                    move |this, range, _window, cx| {
+                    move |this, range, window, cx| {
                         let mut items = Vec::with_capacity(range.end - range.start);
 
                         for ix in range {
                             match &this.entries.get(ix) {
                                 Some(GitListEntry::GitStatusEntry(entry)) => {
-                                    items.push(this.render_entry(ix, entry, has_write_access, cx));
+                                    items.push(this.render_entry(
+                                        ix,
+                                        entry,
+                                        has_write_access,
+                                        window,
+                                        cx,
+                                    ));
                                 }
                                 Some(GitListEntry::Header(header)) => {
-                                    items.push(this.render_header(
+                                    items.push(this.render_list_header(
                                         ix,
                                         header,
                                         has_write_access,
+                                        window,
                                         cx,
                                     ));
                                 }
@@ -1338,7 +1351,7 @@ impl GitPanel {
                 .with_decoration(
                     ui::indent_guides(
                         cx.entity().clone(),
-                        px(10.0),
+                        self.indent_size(window, cx),
                         IndentGuideColors::panel(cx),
                         |this, range, _windows, _cx| {
                             this.entries
@@ -1353,12 +1366,9 @@ impl GitPanel {
                     )
                     .with_render_fn(
                         cx.entity().clone(),
-                        move |_, params, window, cx| {
-                            let left_offset = Checkbox::container_size(cx)
-                                .to_pixels(window.rem_size())
-                                .half();
-                            const PADDING_Y: f32 = 4.;
+                        move |_, params, _, _| {
                             let indent_size = params.indent_size;
+                            let left_offset = indent_size - px(3.0);
                             let item_height = params.item_height;
 
                             params
@@ -1369,7 +1379,7 @@ impl GitPanel {
                                     let offset = if layout.continues_offscreen {
                                         px(0.)
                                     } else {
-                                        px(PADDING_Y)
+                                        px(4.0)
                                     };
                                     let bounds = Bounds::new(
                                         point(
@@ -1405,11 +1415,12 @@ impl GitPanel {
         Label::new(label.into()).color(color).single_line()
     }
 
-    fn render_header(
+    fn render_list_header(
         &self,
         ix: usize,
         header: &GitHeaderEntry,
         has_write_access: bool,
+        _window: &Window,
         cx: &Context<Self>,
     ) -> AnyElement {
         let checkbox = Checkbox::new(header.title(), self.header_state(header.header))
@@ -1420,7 +1431,6 @@ impl GitPanel {
 
         div()
             .w_full()
-            .px_0p5()
             .child(
                 ListHeader::new(header.title())
                     .start_slot(checkbox)
@@ -1438,7 +1448,8 @@ impl GitPanel {
                                 cx,
                             )
                         })
-                    }),
+                    })
+                    .inset(true),
             )
             .into_any_element()
     }
@@ -1448,6 +1459,7 @@ impl GitPanel {
         ix: usize,
         entry: &GitStatusEntry,
         has_write_access: bool,
+        window: &Window,
         cx: &Context<Self>,
     ) -> AnyElement {
         let display_name = entry
@@ -1534,7 +1546,7 @@ impl GitPanel {
             .child(
                 ListItem::new(id)
                     .indent_level(1)
-                    .indent_step_size(px(10.0))
+                    .indent_step_size(Checkbox::container_size(cx).to_pixels(window.rem_size()))
                     .spacing(ListItemSpacing::Sparse)
                     .start_slot(start_slot)
                     .toggle_state(selected)
@@ -1689,16 +1701,14 @@ impl Render for GitPanel {
             }))
             .size_full()
             .overflow_hidden()
-            .py_1()
             .bg(ElevationIndex::Surface.bg(cx))
             .child(self.render_panel_header(window, cx))
-            .child(self.render_divider(cx))
             .child(if has_entries {
-                self.render_entries(has_write_access, cx).into_any_element()
+                self.render_entries(has_write_access, window, cx)
+                    .into_any_element()
             } else {
                 self.render_empty_state(cx).into_any_element()
             })
-            .child(self.render_divider(cx))
             .child(self.render_commit_editor(name_and_email, cx))
     }
 }
@@ -1761,3 +1771,5 @@ impl Panel for GitPanel {
         2
     }
 }
+
+impl PanelHeader for GitPanel {}

crates/panel/Cargo.toml 🔗

@@ -0,0 +1,21 @@
+[package]
+name = "panel"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+name = "panel"
+path = "src/panel.rs"
+
+[dependencies]
+gpui.workspace = true
+ui.workspace = true
+workspace.workspace = true
+
+[features]
+default = []

crates/panel/src/panel.rs 🔗

@@ -0,0 +1,66 @@
+//! # panel
+use gpui::actions;
+use ui::{prelude::*, Tab};
+
+actions!(panel, [NextPanelTab, PreviousPanelTab]);
+
+pub trait PanelHeader: workspace::Panel {
+    fn header_height(&self, cx: &mut App) -> Pixels {
+        Tab::container_height(cx)
+    }
+
+    fn panel_header_container(&self, _window: &mut Window, cx: &mut App) -> Div {
+        h_flex()
+            .h(self.header_height(cx))
+            .w_full()
+            .px_1()
+            .flex_none()
+            .border_b_1()
+            .border_color(cx.theme().colors().border)
+    }
+}
+
+/// Implement this trait to enable a panel to have tabs.
+pub trait PanelTabs: PanelHeader {
+    /// Returns the index of the currently selected tab.
+    fn selected_tab(&self, cx: &mut App) -> usize;
+    /// Selects the tab at the given index.
+    fn select_tab(&self, cx: &mut App, index: usize);
+    /// Moves to the next tab.
+    fn next_tab(&self, _: NextPanelTab, cx: &mut App) -> Self;
+    /// Moves to the previous tab.
+    fn previous_tab(&self, _: PreviousPanelTab, cx: &mut App) -> Self;
+}
+
+#[derive(IntoElement)]
+pub struct PanelTab {}
+
+impl RenderOnce for PanelTab {
+    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
+        div()
+    }
+}
+
+pub fn panel_button(label: impl Into<SharedString>) -> ui::Button {
+    let label = label.into();
+    let id = ElementId::Name(label.clone().to_lowercase().replace(' ', "_").into());
+    ui::Button::new(id, label)
+        .label_size(ui::LabelSize::Small)
+        .layer(ui::ElevationIndex::Surface)
+        .size(ui::ButtonSize::Compact)
+}
+
+pub fn panel_filled_button(label: impl Into<SharedString>) -> ui::Button {
+    panel_button(label).style(ui::ButtonStyle::Filled)
+}
+
+pub fn panel_icon_button(id: impl Into<SharedString>, icon: IconName) -> ui::IconButton {
+    let id = ElementId::Name(id.into());
+    ui::IconButton::new(id, icon)
+        .layer(ui::ElevationIndex::Surface)
+        .size(ui::ButtonSize::Compact)
+}
+
+pub fn panel_filled_icon_button(id: impl Into<SharedString>, icon: IconName) -> ui::IconButton {
+    panel_icon_button(id, icon).style(ui::ButtonStyle::Filled)
+}

crates/workspace/src/workspace.rs 🔗

@@ -21,7 +21,8 @@ use client::{
 };
 use collections::{hash_map, HashMap, HashSet};
 use derive_more::{Deref, DerefMut};
-use dock::{Dock, DockPosition, Panel, PanelButtons, PanelHandle, RESIZE_HANDLE_SIZE};
+pub use dock::Panel;
+use dock::{Dock, DockPosition, PanelButtons, PanelHandle, RESIZE_HANDLE_SIZE};
 use futures::{
     channel::{
         mpsc::{self, UnboundedReceiver, UnboundedSender},

script/new-crate 🔗

@@ -0,0 +1,87 @@
+#!/bin/bash
+
+# Try to make sure we are in the zed repo root
+if [ ! -d "crates" ] || [ ! -d "script" ]; then
+    echo "Error: Run from the \`zed\` repo root"
+    exit 1
+fi
+
+if [ ! -f "Cargo.toml" ]; then
+    echo "Error: Run from the \`zed\` repo root"
+    exit 1
+fi
+
+if [ $# -eq 0 ]; then
+    echo "Usage: $0 <crate_name> [optional_license_flag]"
+    exit 1
+fi
+
+CRATE_NAME="$1"
+
+LICENSE_FLAG=$(echo "${2}" | tr '[:upper:]' '[:lower:]')
+if [[ "$LICENSE_FLAG" == *"apache"* ]]; then
+    LICENSE_MODE="Apache-2.0"
+    LICENSE_FILE="LICENSE-APACHE"
+elif [[ "$LICENSE_FLAG" == *"agpl"* ]]; then
+    LICENSE_MODE="AGPL-3.0-or-later"
+    LICENSE_FILE="LICENSE-AGPL"
+else
+    LICENSE_MODE="GPL-3.0-or-later"
+    LICENSE_FILE="LICENSE"
+fi
+
+if [[ ! "$CRATE_NAME" =~ ^[a-z0-9_]+$ ]]; then
+    echo "Error: Crate name must be lowercase and contain only alphanumeric characters and underscores"
+    exit 1
+fi
+
+CRATE_PATH="crates/$CRATE_NAME"
+mkdir -p "$CRATE_PATH/src"
+
+# Symlink the license
+ln -sf "../../../$LICENSE_FILE" "$CRATE_PATH/LICENSE"
+
+CARGO_TOML_TEMPLATE=$(cat << 'EOF'
+[package]
+name = "$CRATE_NAME"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "$LICENSE_MODE"
+
+[lints]
+workspace = true
+
+[lib]
+name = "$CRATE_NAME"
+path = "src/$CRATE_NAME.rs"
+
+[dependencies]
+anyhow.workspace = true
+gpui.workspace = true
+ui.workspace = true
+util.workspace = true
+
+# Uncomment other workspace dependencies as needed
+# assistant.workspace = true
+# client.workspace = true
+# project.workspace = true
+# settings.workspace = true
+
+[features]
+default = []
+EOF
+)
+
+# Populate template
+CARGO_TOML_CONTENT=$(echo "$CARGO_TOML_TEMPLATE" | sed \
+    -e "s/\$CRATE_NAME/$CRATE_NAME/g" \
+    -e "s/\$LICENSE_MODE/$LICENSE_MODE/g")
+
+echo "$CARGO_TOML_CONTENT" > "$CRATE_PATH/Cargo.toml"
+
+echo "//! # $CRATE_NAME" > "$CRATE_PATH/src/$CRATE_NAME.rs"
+
+echo "Created new crate: $CRATE_NAME in $CRATE_PATH"
+echo "License: $LICENSE_MODE (symlinked from $LICENSE_FILE)"
+echo "Don't forget to add the new crate to the workspace!"