git_ui: Improve design in branch, stash, and worktree pickers (#53798)

Danilo Leal created

Mainly improving label truncation as well as making date/time display
consistent between the branch and stash pickers. In the list item, we
display relative timestamps, while in the tooltip, we display it in
absolute for more details.

Release Notes:

- N/A

Change summary

crates/git_ui/src/branch_picker.rs   | 53 +++++++++++++++++++++--------
crates/git_ui/src/stash_picker.rs    | 47 ++++++++++++++++++++++++++
crates/git_ui/src/worktree_picker.rs |  2 
3 files changed, 85 insertions(+), 17 deletions(-)

Detailed changes

crates/git_ui/src/branch_picker.rs 🔗

@@ -864,7 +864,7 @@ impl PickerDelegate for BranchListDelegate {
     ) -> Option<Self::ListItem> {
         let entry = &self.matches.get(ix)?;
 
-        let (commit_time, author_name, subject) = entry
+        let (commit_time, absolute_time, author_name, subject) = entry
             .as_branch()
             .and_then(|branch| {
                 branch.most_recent_commit.as_ref().map(|commit| {
@@ -879,11 +879,22 @@ impl PickerDelegate for BranchListDelegate {
                         local_offset,
                         time_format::TimestampFormat::Relative,
                     );
+                    let absolute_time = time_format::format_localized_timestamp(
+                        commit_time,
+                        OffsetDateTime::now_utc(),
+                        local_offset,
+                        time_format::TimestampFormat::EnhancedAbsolute,
+                    );
                     let author = commit.author_name.clone();
-                    (Some(formatted_time), Some(author), Some(subject))
+                    (
+                        Some(formatted_time),
+                        Some(absolute_time),
+                        Some(author),
+                        Some(subject),
+                    )
                 })
             })
-            .unwrap_or_else(|| (None, None, None));
+            .unwrap_or_else(|| (None, None, None, None));
 
         let is_head_branch = entry.as_branch().is_some_and(|branch| branch.is_head);
 
@@ -1076,19 +1087,31 @@ impl PickerDelegate for BranchListDelegate {
                                 .when_some(
                                     entry.as_branch().map(|b| b.name().to_string()),
                                     |this, branch_name| {
-                                        this.map(|this| {
-                                            if is_head_branch {
-                                                this.tooltip(move |_, cx| {
-                                                    Tooltip::with_meta(
-                                                        branch_name.clone(),
-                                                        None,
-                                                        "Current Branch",
-                                                        cx,
+                                        let absolute_time = absolute_time.clone();
+                                        this.tooltip({
+                                            let is_head = is_head_branch;
+                                            Tooltip::element(move |_, _| {
+                                                v_flex()
+                                                    .child(Label::new(branch_name.clone()))
+                                                    .when(is_head, |this| {
+                                                        this.child(
+                                                            Label::new("Current Branch")
+                                                                .size(LabelSize::Small)
+                                                                .color(Color::Muted),
+                                                        )
+                                                    })
+                                                    .when_some(
+                                                        absolute_time.clone(),
+                                                        |this, time| {
+                                                            this.child(
+                                                                Label::new(time)
+                                                                    .size(LabelSize::Small)
+                                                                    .color(Color::Muted),
+                                                            )
+                                                        },
                                                     )
-                                                })
-                                            } else {
-                                                this.tooltip(Tooltip::text(branch_name))
-                                            }
+                                                    .into_any_element()
+                                            })
                                         })
                                     },
                                 ),

crates/git_ui/src/stash_picker.rs 🔗

@@ -223,6 +223,7 @@ struct StashEntryMatch {
     entry: StashEntry,
     positions: Vec<usize>,
     formatted_timestamp: String,
+    formatted_absolute_timestamp: String,
 }
 
 pub struct StashListDelegate {
@@ -264,6 +265,17 @@ impl StashListDelegate {
     }
 
     fn format_timestamp(timestamp: i64, timezone: UtcOffset) -> String {
+        let timestamp =
+            OffsetDateTime::from_unix_timestamp(timestamp).unwrap_or(OffsetDateTime::now_utc());
+        time_format::format_localized_timestamp(
+            timestamp,
+            OffsetDateTime::now_utc(),
+            timezone,
+            time_format::TimestampFormat::Relative,
+        )
+    }
+
+    fn format_absolute_timestamp(timestamp: i64, timezone: UtcOffset) -> String {
         let timestamp =
             OffsetDateTime::from_unix_timestamp(timestamp).unwrap_or(OffsetDateTime::now_utc());
         time_format::format_localized_timestamp(
@@ -388,11 +400,14 @@ impl PickerDelegate for StashListDelegate {
                     .into_iter()
                     .map(|entry| {
                         let formatted_timestamp = Self::format_timestamp(entry.timestamp, timezone);
+                        let formatted_absolute_timestamp =
+                            Self::format_absolute_timestamp(entry.timestamp, timezone);
 
                         StashEntryMatch {
                             entry,
                             positions: Vec::new(),
                             formatted_timestamp,
+                            formatted_absolute_timestamp,
                         }
                     })
                     .collect()
@@ -421,11 +436,14 @@ impl PickerDelegate for StashListDelegate {
                 .map(|candidate| {
                     let entry = all_stash_entries[candidate.candidate_id].clone();
                     let formatted_timestamp = Self::format_timestamp(entry.timestamp, timezone);
+                    let formatted_absolute_timestamp =
+                        Self::format_absolute_timestamp(entry.timestamp, timezone);
 
                     StashEntryMatch {
                         entry,
                         positions: candidate.positions,
                         formatted_timestamp,
+                        formatted_absolute_timestamp,
                     }
                 })
                 .collect()
@@ -544,6 +562,7 @@ impl PickerDelegate for StashListDelegate {
                 .toggle_state(selected)
                 .child(
                     h_flex()
+                        .min_w_0()
                         .w_full()
                         .gap_2p5()
                         .child(
@@ -551,7 +570,33 @@ impl PickerDelegate for StashListDelegate {
                                 .size(IconSize::Small)
                                 .color(Color::Muted),
                         )
-                        .child(div().w_full().child(stash_label).child(branch_info)),
+                        .child(
+                            v_flex()
+                                .id(format!("stash-tooltip-{ix}"))
+                                .min_w_0()
+                                .w_full()
+                                .child(stash_label)
+                                .child(branch_info)
+                                .tooltip({
+                                    let stash_message = Self::format_message(
+                                        entry_match.entry.index,
+                                        &entry_match.entry.message,
+                                    );
+                                    let absolute_timestamp =
+                                        entry_match.formatted_absolute_timestamp.clone();
+
+                                    Tooltip::element(move |_, _| {
+                                        v_flex()
+                                            .child(Label::new(stash_message.clone()))
+                                            .child(
+                                                Label::new(absolute_timestamp.clone())
+                                                    .size(LabelSize::Small)
+                                                    .color(Color::Muted),
+                                            )
+                                            .into_any_element()
+                                    })
+                                }),
+                        ),
                 )
                 .end_slot(
                     h_flex()

crates/git_ui/src/worktree_picker.rs 🔗

@@ -889,7 +889,7 @@ impl PickerDelegate for WorktreeListDelegate {
                                 })
                                 .size(IconSize::Small),
                         )
-                        .child(v_flex().w_full().child(branch_name).map(|this| {
+                        .child(v_flex().w_full().min_w_0().child(branch_name).map(|this| {
                             if entry.is_new {
                                 this.child(
                                     Label::new(sublabel)