From 4b372dfb38fa27b25d217d2e630a8304d542f758 Mon Sep 17 00:00:00 2001 From: Taylor Eernisse Date: Sat, 14 Feb 2026 10:01:07 -0500 Subject: [PATCH] refactor(list): polish list commands with icons, compact timestamps, and styled discussions Phase 3 of the UX overhaul. Enhances the issues, merge requests, and notes list displays with visual indicators and improved formatting. List display changes (src/cli/commands/list.rs): - Add state icons to issues (opened/closed) and merge requests (opened/merged/closed) using Icons:: helpers alongside text labels - Replace [DRAFT] prefix with Icons::mr_draft() glyph for draft MRs - Switch from format_relative_time to format_relative_time_compact for tighter column widths in tabular output - Switch from format_labels to format_labels_bare for unlabeled style - Change format_discussions() return type from String to StyledCell so unresolved counts render with Theme::warning() color inline - Bold the section headers ("Issues", "Merge Requests", "Notes") with count separated from the label for cleaner scanning - Import Icons from render module Test updates (src/cli/commands/list_tests.rs): - Update format_discussions tests to assert on StyledCell.text field instead of raw String, since the function now returns styled output - The unresolved-count test checks starts_with/contains to handle embedded ANSI escape codes from Theme::warning() Co-Authored-By: Claude Opus 4.6 --- src/cli/commands/list.rs | 55 +++++++++++++++++++--------------- src/cli/commands/list_tests.rs | 9 ++++-- 2 files changed, 37 insertions(+), 27 deletions(-) diff --git a/src/cli/commands/list.rs b/src/cli/commands/list.rs index 874ecbf..65b7bf3 100644 --- a/src/cli/commands/list.rs +++ b/src/cli/commands/list.rs @@ -1,4 +1,4 @@ -use crate::cli::render::{self, Align, StyledCell, Table as LoreTable, Theme}; +use crate::cli::render::{self, Align, Icons, StyledCell, Table as LoreTable, Theme}; use rusqlite::Connection; use serde::Serialize; @@ -657,15 +657,17 @@ fn format_assignees(assignees: &[String]) -> String { } } -fn format_discussions(total: i64, unresolved: i64) -> String { +fn format_discussions(total: i64, unresolved: i64) -> StyledCell { if total == 0 { - return String::new(); + return StyledCell::plain(String::new()); } if unresolved > 0 { - format!("{total}/{unresolved}!") + let text = format!("{total}/"); + let warn = Theme::warning().render(&format!("{unresolved}!")); + StyledCell::plain(format!("{text}{warn}")) } else { - format!("{total}") + StyledCell::plain(format!("{total}")) } } @@ -681,7 +683,8 @@ pub fn print_list_issues(result: &ListResult) { } println!( - "Issues (showing {} of {})\n", + "{} {} of {}\n", + Theme::bold().render("Issues"), result.issues.len(), result.total_count ); @@ -698,16 +701,17 @@ pub fn print_list_issues(result: &ListResult) { for issue in &result.issues { let title = render::truncate(&issue.title, 45); - let relative_time = render::format_relative_time(issue.updated_at); - let labels = render::format_labels(&issue.labels, 2); + let relative_time = render::format_relative_time_compact(issue.updated_at); + let labels = render::format_labels_bare(&issue.labels, 2); let assignee = format_assignees(&issue.assignees); let discussions = format_discussions(issue.discussion_count, issue.unresolved_count); - let state_cell = if issue.state == "opened" { - StyledCell::styled(&issue.state, Theme::success()) + let (icon, state_style) = if issue.state == "opened" { + (Icons::issue_opened(), Theme::success()) } else { - StyledCell::styled(&issue.state, Theme::dim()) + (Icons::issue_closed(), Theme::dim()) }; + let state_cell = StyledCell::styled(format!("{icon} {}", issue.state), state_style); let mut row = vec![ StyledCell::styled(format!("#{}", issue.iid), Theme::info()), @@ -730,7 +734,7 @@ pub fn print_list_issues(result: &ListResult) { row.extend([ StyledCell::styled(assignee, Theme::accent()), StyledCell::styled(labels, Theme::warning()), - StyledCell::plain(discussions), + discussions, StyledCell::styled(relative_time, Theme::dim()), ]); table.add_row(row); @@ -783,7 +787,8 @@ pub fn print_list_mrs(result: &MrListResult) { } println!( - "Merge Requests (showing {} of {})\n", + "{} {} of {}\n", + Theme::bold().render("Merge Requests"), result.mrs.len(), result.total_count ); @@ -796,22 +801,23 @@ pub fn print_list_mrs(result: &MrListResult) { for mr in &result.mrs { let title = if mr.draft { - format!("[DRAFT] {}", render::truncate(&mr.title, 38)) + format!("{} {}", Icons::mr_draft(), render::truncate(&mr.title, 42)) } else { render::truncate(&mr.title, 45) }; - let relative_time = render::format_relative_time(mr.updated_at); + let relative_time = render::format_relative_time_compact(mr.updated_at); let branches = format_branches(&mr.target_branch, &mr.source_branch, 25); let discussions = format_discussions(mr.discussion_count, mr.unresolved_count); - let state_cell = match mr.state.as_str() { - "opened" => StyledCell::styled(&mr.state, Theme::success()), - "merged" => StyledCell::styled(&mr.state, Theme::accent()), - "closed" => StyledCell::styled(&mr.state, Theme::error()), - "locked" => StyledCell::styled(&mr.state, Theme::warning()), - _ => StyledCell::styled(&mr.state, Theme::dim()), + let (icon, style) = match mr.state.as_str() { + "opened" => (Icons::mr_opened(), Theme::success()), + "merged" => (Icons::mr_merged(), Theme::accent()), + "closed" => (Icons::mr_closed(), Theme::error()), + "locked" => (Icons::mr_opened(), Theme::warning()), + _ => (Icons::mr_opened(), Theme::dim()), }; + let state_cell = StyledCell::styled(format!("{icon} {}", mr.state), style); table.add_row(vec![ StyledCell::styled(format!("!{}", mr.iid), Theme::info()), @@ -822,7 +828,7 @@ pub fn print_list_mrs(result: &MrListResult) { Theme::accent(), ), StyledCell::styled(branches, Theme::info()), - StyledCell::plain(discussions), + discussions, StyledCell::styled(relative_time, Theme::dim()), ]); } @@ -909,7 +915,8 @@ pub fn print_list_notes(result: &NoteListResult) { } println!( - "Notes (showing {} of {})\n", + "{} {} of {}\n", + Theme::bold().render("Notes"), result.notes.len(), result.total_count ); @@ -934,7 +941,7 @@ pub fn print_list_notes(result: &NoteListResult) { .unwrap_or_default(); let path = format_note_path(note.position_new_path.as_deref(), note.position_new_line); let parent = format_note_parent(note.noteable_type.as_deref(), note.parent_iid); - let relative_time = render::format_relative_time(note.created_at); + let relative_time = render::format_relative_time_compact(note.created_at); let note_type = format_note_type(note.note_type.as_deref()); table.add_row(vec![ diff --git a/src/cli/commands/list_tests.rs b/src/cli/commands/list_tests.rs index 4780c79..3087f62 100644 --- a/src/cli/commands/list_tests.rs +++ b/src/cli/commands/list_tests.rs @@ -62,17 +62,20 @@ fn format_labels_overflow() { #[test] fn format_discussions_empty() { - assert_eq!(format_discussions(0, 0), ""); + assert_eq!(format_discussions(0, 0).text, ""); } #[test] fn format_discussions_no_unresolved() { - assert_eq!(format_discussions(5, 0), "5"); + assert_eq!(format_discussions(5, 0).text, "5"); } #[test] fn format_discussions_with_unresolved() { - assert_eq!(format_discussions(5, 2), "5/2!"); + let cell = format_discussions(5, 2); + // Text contains styled ANSI for warning-colored unresolved count + assert!(cell.text.starts_with("5/"), "got: {}", cell.text); + assert!(cell.text.contains("2!"), "got: {}", cell.text); } // -----------------------------------------------------------------------