From ba9d083088af8836f337b51cd4016ce523dc88ed Mon Sep 17 00:00:00 2001 From: vikingowl Date: Mon, 13 Oct 2025 22:32:32 +0200 Subject: [PATCH] feat(tui): add git status colors to file tree UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Map git badges and cleanliness states to specific `Color` values and modifiers. - Apply these colors to file icons, filenames, and markers in the UI. - Propagate the most relevant dirty badge from child nodes up to parent directories. - Extend the help overlay with a “GIT COLORS” section describing the new color legend. --- crates/owlen-tui/src/state/file_tree.rs | 27 ++++++++++----- crates/owlen-tui/src/ui.rs | 46 ++++++++++++++++++++++--- 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/crates/owlen-tui/src/state/file_tree.rs b/crates/owlen-tui/src/state/file_tree.rs index 90112d2..ec632dc 100644 --- a/crates/owlen-tui/src/state/file_tree.rs +++ b/crates/owlen-tui/src/state/file_tree.rs @@ -585,22 +585,31 @@ fn propagate_directory_git_state(nodes: &mut [FileNode]) { continue; } let mut has_dirty = false; + let mut dirty_badge: Option = None; let mut has_staged = false; for child in nodes[idx].children.clone() { - match nodes.get(child).map(|n| n.git.cleanliness) { - Some('●') => { - has_dirty = true; - break; + if let Some(child_node) = nodes.get(child) { + match child_node.git.cleanliness { + '●' => { + has_dirty = true; + let candidate = child_node.git.badge.unwrap_or('M'); + dirty_badge = Some(match (dirty_badge, candidate) { + (Some('D'), _) | (_, 'D') => 'D', + (Some('U'), _) | (_, 'U') => 'U', + (Some(existing), _) => existing, + (None, new_badge) => new_badge, + }); + } + '○' => { + has_staged = true; + } + _ => {} } - Some('○') => { - has_staged = true; - } - _ => {} } } nodes[idx].git = if has_dirty { - GitDecoration::dirty(None) + GitDecoration::dirty(dirty_badge) } else if has_staged { GitDecoration::staged(None) } else { diff --git a/crates/owlen-tui/src/ui.rs b/crates/owlen-tui/src/ui.rs index 7e9f4b5..ca8ba8a 100644 --- a/crates/owlen-tui/src/ui.rs +++ b/crates/owlen-tui/src/ui.rs @@ -1,7 +1,7 @@ use pathdiff::diff_paths; use ratatui::Frame; use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; -use ratatui::style::{Modifier, Style}; +use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}; use serde_json; @@ -689,11 +689,37 @@ fn render_file_tree(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { }; spans.push(Span::styled(toggle_symbol.to_string(), guide_style)); + let mut git_color: Option = match node.git.badge { + Some('D') => Some(Color::LightRed), + Some('A') => Some(Color::LightGreen), + Some('R') | Some('C') => Some(Color::Yellow), + Some('U') => Some(Color::Magenta), + Some('M') => Some(Color::Yellow), + _ => None, + }; + let mut git_modifiers = Modifier::empty(); + if let Some('D') = node.git.badge { + git_modifiers |= Modifier::ITALIC; + } + if let Some('U') = node.git.badge { + git_modifiers |= Modifier::BOLD; + } + if git_color.is_none() { + git_color = match node.git.cleanliness { + '○' => Some(Color::LightYellow), + '●' => Some(Color::Yellow), + _ => None, + }; + } + let mut icon_style = if node.is_dir { Style::default().fg(theme.info) } else { Style::default().fg(theme.text) }; + if let Some(color) = git_color { + icon_style = icon_style.fg(color); + } if !has_focus && !is_selected { icon_style = icon_style.add_modifier(Modifier::DIM); } @@ -705,6 +731,9 @@ fn render_file_tree(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { let is_unsaved = !node.is_dir && unsaved_paths.contains(&node.path); let mut name_style = Style::default().fg(theme.text); + if let Some(color) = git_color { + name_style = name_style.fg(color); + } if node.is_dir { name_style = name_style.add_modifier(Modifier::BOLD); } @@ -714,12 +743,16 @@ fn render_file_tree(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { if is_unsaved { name_style = name_style.add_modifier(Modifier::ITALIC); } + if !git_modifiers.is_empty() { + name_style = name_style.add_modifier(git_modifiers); + } spans.push(Span::styled(node.name.clone(), name_style)); let mut marker_spans: Vec> = Vec::new(); + let marker_color = git_color.unwrap_or(theme.info); if node.git.cleanliness != '✓' { - marker_spans.push(Span::styled("*", Style::default().fg(theme.info))); + marker_spans.push(Span::styled("*", Style::default().fg(marker_color))); } if is_unsaved { marker_spans.push(Span::styled( @@ -740,7 +773,7 @@ fn render_file_tree(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { if let Some(badge) = node.git.badge { marker_spans.push(Span::styled( badge.to_string(), - Style::default().fg(theme.info), + Style::default().fg(marker_color), )); } @@ -758,7 +791,7 @@ fn render_file_tree(frame: &mut Frame<'_>, area: Rect, app: &mut ChatApp) { if is_selected { line_style = line_style.bg(theme.selection_bg).fg(theme.selection_fg); } else if !has_focus { - line_style = line_style.fg(theme.text).add_modifier(Modifier::DIM); + line_style = line_style.add_modifier(Modifier::DIM); } items.push(ListItem::new(Line::from(spans)).style(line_style)); @@ -3071,6 +3104,11 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) { Line::from(" :files, :explorer → toggle files panel"), Line::from(" Ctrl+←/→ → resize files panel"), Line::from(" Ctrl+↑/↓ → resize chat/thinking split"), + Line::from(vec![Span::styled( + "GIT COLORS", + Style::default().add_modifier(Modifier::BOLD).fg(theme.info), + )]), + Line::from(" Green → added · Yellow → modified/staged · Red → deleted/conflict"), ], 1 => vec![ // Editing