notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit fc1c59e13103ba84f90e2784f145e727bd8778ce
parent 08cb175e03da4e6410c19db2d30d5e369cd2b587
Author: William Casarin <jb55@jb55.com>
Date:   Fri, 13 Feb 2026 09:54:37 -0800

dave: redesign git status bar to match UI style

Snapshot cache data to fix borrow issue and keep expand arrow inline.
Remove background frame for cleaner look, use clickable labels instead
of small_button, add extreme_bg_color frame for expanded file list
matching diff style, and use weak styling for visual hierarchy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Diffstat:
Mcrates/notedeck_dave/src/ui/dave.rs | 6+++---
Mcrates/notedeck_dave/src/ui/git_status_ui.rs | 236+++++++++++++++++++++++++++++++++++++++++--------------------------------------
2 files changed, 126 insertions(+), 116 deletions(-)

diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs @@ -255,15 +255,15 @@ impl<'a> DaveUi<'a> { if let Some(git_status) = &mut self.git_status { // Explicitly reserve height so bottom_up layout // keeps the chat ScrollArea from overlapping. - let h = if git_status.expanded { 200.0 } else { 30.0 }; + let h = if git_status.expanded { 200.0 } else { 24.0 }; let w = ui.available_width(); ui.allocate_ui(egui::vec2(w, h), |ui| { egui::Frame::new() .outer_margin(egui::Margin { left: margin, right: margin, - top: 0, - bottom: 4, + top: 4, + bottom: 0, }) .show(ui, |ui| { git_status_ui::git_status_bar_ui(git_status, ui); diff --git a/crates/notedeck_dave/src/ui/git_status_ui.rs b/crates/notedeck_dave/src/ui/git_status_ui.rs @@ -6,142 +6,152 @@ const ADDED_COLOR: Color32 = Color32::from_rgb(60, 180, 60); const DELETED_COLOR: Color32 = Color32::from_rgb(200, 60, 60); const UNTRACKED_COLOR: Color32 = Color32::from_rgb(128, 128, 128); -/// Render the git status bar. Call `cache.request_refresh()` externally if needed. +/// Snapshot of git status data extracted from the cache to avoid +/// borrow conflicts when mutating `cache.expanded`. +struct StatusSnapshot { + branch: Option<String>, + modified: usize, + added: usize, + deleted: usize, + untracked: usize, + is_clean: bool, + files: Vec<(String, String)>, // (status, path) +} + +impl StatusSnapshot { + fn from_cache(cache: &GitStatusCache) -> Option<Result<Self, ()>> { + match cache.current() { + Some(Ok(data)) => Some(Ok(StatusSnapshot { + branch: data.branch.clone(), + modified: data.modified_count(), + added: data.added_count(), + deleted: data.deleted_count(), + untracked: data.untracked_count(), + is_clean: data.is_clean(), + files: data + .files + .iter() + .map(|f| (f.status.clone(), f.path.clone())) + .collect(), + })), + Some(Err(_)) => Some(Err(())), + None => None, + } + } +} + +/// Render the git status bar. pub fn git_status_bar_ui(cache: &mut GitStatusCache, ui: &mut Ui) { - egui::Frame::new() - .fill(ui.visuals().faint_bg_color) - .inner_margin(egui::Margin::symmetric(8, 4)) - .corner_radius(6.0) - .show(ui, |ui| { - // vertical() forces top-down ordering inside this frame, - // preventing the parent's bottom_up layout from pushing the - // expanded file list above the header into the chat area. - ui.vertical(|ui| { - let is_dirty = matches!(cache.current(), Some(Ok(data)) if !data.is_clean()); + // Snapshot data so we can freely mutate cache.expanded below + let snapshot = StatusSnapshot::from_cache(cache); + + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; - // Only show expand toggle when there are files to list - if is_dirty { - let arrow = if cache.expanded { "\u{25BC}" } else { "\u{25B6}" }; - if ui.small_button(arrow).clicked() { - cache.expanded = !cache.expanded; + match &snapshot { + Some(Ok(snap)) => { + // Show expand arrow only when dirty + if !snap.is_clean { + let arrow = if cache.expanded { + "\u{25BC}" + } else { + "\u{25B6}" + }; + if ui + .add( + egui::Label::new(RichText::new(arrow).weak().monospace().size(9.0)) + .sense(egui::Sense::click()), + ) + .clicked() + { + cache.expanded = !cache.expanded; + } } - } - ui.horizontal(|ui| { - match cache.current() { - Some(Ok(data)) => { - // Branch name - if let Some(branch) = &data.branch { - ui.label( - RichText::new(format!("git: {}", branch)) - .monospace() - .size(11.0), - ); - } else { - ui.label( - RichText::new("git: detached").monospace().size(11.0), - ); - } + // Branch name + let branch_text = snap.branch.as_deref().unwrap_or("detached"); + ui.label(RichText::new(branch_text).weak().monospace().size(11.0)); - if data.is_clean() { - ui.label(RichText::new("clean").weak().size(11.0)); - } else { - let m = data.modified_count(); - let a = data.added_count(); - let d = data.deleted_count(); - let u = data.untracked_count(); - if m > 0 { - ui.label( - RichText::new(format!("~{}", m)) - .color(MODIFIED_COLOR) - .monospace() - .size(11.0), - ); - } - if a > 0 { - ui.label( - RichText::new(format!("+{}", a)) - .color(ADDED_COLOR) - .monospace() - .size(11.0), - ); - } - if d > 0 { - ui.label( - RichText::new(format!("-{}", d)) - .color(DELETED_COLOR) - .monospace() - .size(11.0), - ); - } - if u > 0 { - ui.label( - RichText::new(format!("?{}", u)) - .color(UNTRACKED_COLOR) - .monospace() - .size(11.0), - ); - } - } - } - Some(Err(_)) => { - ui.label( - RichText::new("git: not available").weak().size(11.0), - ); - } - None => { - ui.spinner(); - ui.label( - RichText::new("checking git...").weak().size(11.0), - ); - } + if snap.is_clean { + ui.label(RichText::new("clean").weak().size(11.0)); + } else { + count_label(ui, "~", snap.modified, MODIFIED_COLOR); + count_label(ui, "+", snap.added, ADDED_COLOR); + count_label(ui, "-", snap.deleted, DELETED_COLOR); + count_label(ui, "?", snap.untracked, UNTRACKED_COLOR); } + } + Some(Err(_)) => { + ui.label(RichText::new("git: not available").weak().size(11.0)); + } + None => { + ui.spinner(); + ui.label(RichText::new("checking git...").weak().size(11.0)); + } + } - // Refresh button (right-aligned) - ui.with_layout( - egui::Layout::right_to_left(egui::Align::Center), - |ui| { - if ui - .small_button("\u{21BB}") - .on_hover_text("Refresh git status") - .clicked() - { - cache.request_refresh(); - } - }, - ); - }); + // Refresh button (right-aligned) + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add( + egui::Label::new(RichText::new("\u{21BB}").weak().size(12.0)) + .sense(egui::Sense::click()), + ) + .on_hover_text("Refresh git status") + .clicked() + { + cache.request_refresh(); + } + }); + }); - // Expanded file list - if cache.expanded { - if let Some(Ok(data)) = cache.current() { - if !data.files.is_empty() { - ui.add_space(4.0); + // Expanded file list + if cache.expanded { + if let Some(Ok(snap)) = &snapshot { + if !snap.files.is_empty() { + ui.add_space(4.0); + + egui::Frame::new() + .fill(ui.visuals().extreme_bg_color) + .inner_margin(egui::Margin::symmetric(8, 4)) + .corner_radius(4.0) + .show(ui, |ui| { egui::ScrollArea::vertical() .max_height(150.0) .show(ui, |ui| { - for entry in &data.files { + for (status, path) in &snap.files { ui.horizontal(|ui| { - let color = status_color(&entry.status); + ui.spacing_mut().item_spacing.x = 8.0; + let color = status_color(status); ui.label( - RichText::new(&entry.status) + RichText::new(status) .monospace() .size(11.0) .color(color), ); ui.label( - RichText::new(&entry.path) - .monospace() - .size(11.0), + RichText::new(path).monospace().size(11.0).weak(), ); }); } }); - } - } + }); } - }); - }); + } + } + }); +} + +fn count_label(ui: &mut Ui, prefix: &str, count: usize, color: Color32) { + if count > 0 { + ui.label( + RichText::new(format!("{}{}", prefix, count)) + .color(color) + .monospace() + .size(11.0), + ); + } } fn status_color(status: &str) -> Color32 {