From d0744039efb2fbdd86d9a01a583552996bda8feb Mon Sep 17 00:00:00 2001 From: Taylor Eernisse Date: Sat, 14 Feb 2026 10:02:37 -0500 Subject: [PATCH] refactor(show): polish issue and MR detail views with section dividers and icons Phase 4 of the UX overhaul. Restructures the show issue and show MR detail displays with consistent section layout, state icons, and improved typography. Issue detail changes: - Replace bold header + box-drawing underline with indented title using Theme::bold() for the title text only - Organize fields into named sections using render::section_divider(): Details, Development, Description, Discussions - Add state icons (Icons::issue_opened/closed) alongside text labels - Add relative time in parentheses next to Created/Updated dates - Switch labels from "Labels: (none)" to only showing when present, using format_labels_bare for clean comma-separated output - Move URL and confidential indicator into Details section - Closing MRs show state-colored icons (merged/opened/closed) - Discussions use section_divider instead of bold text, remove colons from author lines, adjust wrap widths for consistent indentation MR detail changes: - Same section-divider layout: Details, Description, Discussions - State icons for opened/merged/closed using Icons::mr_* helpers - Draft indicator uses Icons::mr_draft() instead of [Draft] text prefix - Relative times added to Created, Updated, Merged, Closed dates - Reviewers and Assignees fields aligned with fixed-width labels - Labels shown only when present, using format_labels_bare - Discussion formatting matches issue detail style Both views use 5-space left indent for field alignment and consistent wrap widths (72 for descriptions, 68/66 for discussion notes/replies). Co-Authored-By: Claude Opus 4.6 --- src/cli/commands/show.rs | 273 +++++++++++++++++++++++---------------- 1 file changed, 163 insertions(+), 110 deletions(-) diff --git a/src/cli/commands/show.rs b/src/cli/commands/show.rs index 3e7913f..41de18d 100644 --- a/src/cli/commands/show.rs +++ b/src/cli/commands/show.rs @@ -1,4 +1,4 @@ -use crate::cli::render::{self, Theme}; +use crate::cli::render::{self, Icons, Theme}; use rusqlite::Connection; use serde::Serialize; @@ -614,33 +614,47 @@ fn wrap_text(text: &str, width: usize, indent: &str) -> String { } pub fn print_show_issue(issue: &IssueDetail) { - let header = format!("Issue #{}: {}", issue.iid, issue.title); - println!("{}", Theme::bold().render(&header)); - println!("{}", "\u{2501}".repeat(header.len().min(80))); - println!(); + // Title line + println!( + " Issue #{}: {}", + issue.iid, + Theme::bold().render(&issue.title), + ); - println!("Ref: {}", Theme::dim().render(&issue.references_full)); - println!("Project: {}", Theme::info().render(&issue.project_path)); + // Details section + println!("{}", render::section_divider("Details")); - let state_styled = if issue.state == "opened" { - Theme::success().render(&issue.state) + println!( + " Ref {}", + Theme::muted().render(&issue.references_full) + ); + println!( + " Project {}", + Theme::info().render(&issue.project_path) + ); + + let (icon, state_style) = if issue.state == "opened" { + (Icons::issue_opened(), Theme::success()) } else { - Theme::dim().render(&issue.state) + (Icons::issue_closed(), Theme::dim()) }; - println!("State: {}", state_styled); - - if issue.confidential { - println!(" {}", Theme::error().bold().render("CONFIDENTIAL")); - } + println!( + " State {}", + state_style.render(&format!("{icon} {}", issue.state)) + ); if let Some(status) = &issue.status_name { println!( - "Status: {}", + " Status {}", render::style_with_hex(status, issue.status_color.as_deref()) ); } - println!("Author: @{}", issue.author_username); + if issue.confidential { + println!(" {}", Theme::error().bold().render("CONFIDENTIAL")); + } + + println!(" Author @{}", issue.author_username); if !issue.assignees.is_empty() { let label = if issue.assignees.len() > 1 { @@ -649,69 +663,82 @@ pub fn print_show_issue(issue: &IssueDetail) { "Assignee" }; println!( - "{}:{} {}", + " {}{} {}", label, - " ".repeat(10 - label.len()), + " ".repeat(12 - label.len()), issue .assignees .iter() - .map(|a| format!("@{}", a)) + .map(|a| format!("@{a}")) .collect::>() .join(", ") ); } - println!("Created: {}", format_date(issue.created_at)); - println!("Updated: {}", format_date(issue.updated_at)); + println!( + " Created {} ({})", + format_date(issue.created_at), + render::format_relative_time_compact(issue.created_at), + ); + println!( + " Updated {} ({})", + format_date(issue.updated_at), + render::format_relative_time_compact(issue.updated_at), + ); if let Some(closed_at) = &issue.closed_at { - println!("Closed: {}", closed_at); + println!(" Closed {closed_at}"); } if let Some(due) = &issue.due_date { - println!("Due: {}", due); + println!(" Due {due}"); } if let Some(ms) = &issue.milestone { - println!("Milestone: {}", ms); + println!(" Milestone {ms}"); } - if issue.labels.is_empty() { - println!("Labels: {}", Theme::dim().render("(none)")); - } else { - println!("Labels: {}", issue.labels.join(", ")); - } - - if !issue.closing_merge_requests.is_empty() { - println!(); - println!("{}", Theme::bold().render("Development:")); - for mr in &issue.closing_merge_requests { - let state_indicator = match mr.state.as_str() { - "merged" => Theme::success().render(&mr.state), - "opened" => Theme::info().render(&mr.state), - "closed" => Theme::error().render(&mr.state), - _ => Theme::dim().render(&mr.state), - }; - println!(" !{} {} ({})", mr.iid, mr.title, state_indicator); - } + if !issue.labels.is_empty() { + println!( + " Labels {}", + render::format_labels_bare(&issue.labels, issue.labels.len()) + ); } if let Some(url) = &issue.web_url { - println!("URL: {}", Theme::dim().render(url)); + println!(" URL {}", Theme::muted().render(url)); } - println!(); + // Development section + if !issue.closing_merge_requests.is_empty() { + println!("{}", render::section_divider("Development")); + for mr in &issue.closing_merge_requests { + let (mr_icon, mr_style) = match mr.state.as_str() { + "merged" => (Icons::mr_merged(), Theme::accent()), + "opened" => (Icons::mr_opened(), Theme::success()), + "closed" => (Icons::mr_closed(), Theme::error()), + _ => (Icons::mr_opened(), Theme::dim()), + }; + println!( + " {} !{} {} {}", + mr_style.render(mr_icon), + mr.iid, + mr.title, + mr_style.render(&mr.state), + ); + } + } - println!("{}", Theme::bold().render("Description:")); + // Description section + println!("{}", render::section_divider("Description")); if let Some(desc) = &issue.description { - let wrapped = wrap_text(desc, 76, " "); - println!(" {}", wrapped); + let wrapped = wrap_text(desc, 72, " "); + println!(" {wrapped}"); } else { - println!(" {}", Theme::dim().render("(no description)")); + println!(" {}", Theme::muted().render("(no description)")); } - println!(); - + // Discussions section let user_discussions: Vec<&DiscussionDetail> = issue .discussions .iter() @@ -719,13 +746,12 @@ pub fn print_show_issue(issue: &IssueDetail) { .collect(); if user_discussions.is_empty() { - println!("{}", Theme::dim().render("Discussions: (none)")); + println!("\n {}", Theme::muted().render("No discussions")); } else { println!( "{}", - Theme::bold().render(&format!("Discussions ({}):", user_discussions.len())) + render::section_divider(&format!("Discussions ({})", user_discussions.len())) ); - println!(); for discussion in user_discussions { let user_notes: Vec<&NoteDetail> = @@ -733,22 +759,22 @@ pub fn print_show_issue(issue: &IssueDetail) { if let Some(first_note) = user_notes.first() { println!( - " {} ({}):", + " {} {}", Theme::info().render(&format!("@{}", first_note.author_username)), - format_date(first_note.created_at) + format_date(first_note.created_at), ); - let wrapped = wrap_text(&first_note.body, 72, " "); - println!(" {}", wrapped); + let wrapped = wrap_text(&first_note.body, 68, " "); + println!(" {wrapped}"); println!(); for reply in user_notes.iter().skip(1) { println!( - " {} ({}):", + " {} {}", Theme::info().render(&format!("@{}", reply.author_username)), - format_date(reply.created_at) + format_date(reply.created_at), ); - let wrapped = wrap_text(&reply.body, 68, " "); - println!(" {}", wrapped); + let wrapped = wrap_text(&reply.body, 66, " "); + println!(" {wrapped}"); println!(); } } @@ -757,36 +783,49 @@ pub fn print_show_issue(issue: &IssueDetail) { } pub fn print_show_mr(mr: &MrDetail) { - let draft_prefix = if mr.draft { "[Draft] " } else { "" }; - let header = format!("MR !{}: {}{}", mr.iid, draft_prefix, mr.title); - println!("{}", Theme::bold().render(&header)); - println!("{}", "\u{2501}".repeat(header.len().min(80))); - println!(); - - println!("Project: {}", Theme::info().render(&mr.project_path)); - - let state_styled = match mr.state.as_str() { - "opened" => Theme::success().render(&mr.state), - "merged" => Theme::accent().render(&mr.state), - "closed" => Theme::error().render(&mr.state), - _ => Theme::dim().render(&mr.state), + // Title line + let draft_prefix = if mr.draft { + format!("{} ", Icons::mr_draft()) + } else { + String::new() }; - println!("State: {}", state_styled); + println!( + " MR !{}: {}{}", + mr.iid, + draft_prefix, + Theme::bold().render(&mr.title), + ); + + // Details section + println!("{}", render::section_divider("Details")); + + println!(" Project {}", Theme::info().render(&mr.project_path)); + + let (icon, state_style) = match mr.state.as_str() { + "opened" => (Icons::mr_opened(), Theme::success()), + "merged" => (Icons::mr_merged(), Theme::accent()), + "closed" => (Icons::mr_closed(), Theme::error()), + _ => (Icons::mr_opened(), Theme::dim()), + }; + println!( + " State {}", + state_style.render(&format!("{icon} {}", mr.state)) + ); println!( - "Branches: {} -> {}", + " Branches {} -> {}", Theme::info().render(&mr.source_branch), Theme::warning().render(&mr.target_branch) ); - println!("Author: @{}", mr.author_username); + println!(" Author @{}", mr.author_username); if !mr.assignees.is_empty() { println!( - "Assignees: {}", + " Assignees {}", mr.assignees .iter() - .map(|a| format!("@{}", a)) + .map(|a| format!("@{a}")) .collect::>() .join(", ") ); @@ -794,48 +833,63 @@ pub fn print_show_mr(mr: &MrDetail) { if !mr.reviewers.is_empty() { println!( - "Reviewers: {}", + " Reviewers {}", mr.reviewers .iter() - .map(|r| format!("@{}", r)) + .map(|r| format!("@{r}")) .collect::>() .join(", ") ); } - println!("Created: {}", format_date(mr.created_at)); - println!("Updated: {}", format_date(mr.updated_at)); + println!( + " Created {} ({})", + format_date(mr.created_at), + render::format_relative_time_compact(mr.created_at), + ); + println!( + " Updated {} ({})", + format_date(mr.updated_at), + render::format_relative_time_compact(mr.updated_at), + ); if let Some(merged_at) = mr.merged_at { - println!("Merged: {}", format_date(merged_at)); + println!( + " Merged {} ({})", + format_date(merged_at), + render::format_relative_time_compact(merged_at), + ); } if let Some(closed_at) = mr.closed_at { - println!("Closed: {}", format_date(closed_at)); + println!( + " Closed {} ({})", + format_date(closed_at), + render::format_relative_time_compact(closed_at), + ); } - if mr.labels.is_empty() { - println!("Labels: {}", Theme::dim().render("(none)")); - } else { - println!("Labels: {}", mr.labels.join(", ")); + if !mr.labels.is_empty() { + println!( + " Labels {}", + render::format_labels_bare(&mr.labels, mr.labels.len()) + ); } if let Some(url) = &mr.web_url { - println!("URL: {}", Theme::dim().render(url)); + println!(" URL {}", Theme::muted().render(url)); } - println!(); - - println!("{}", Theme::bold().render("Description:")); + // Description section + println!("{}", render::section_divider("Description")); if let Some(desc) = &mr.description { - let wrapped = wrap_text(desc, 76, " "); - println!(" {}", wrapped); + let wrapped = wrap_text(desc, 72, " "); + println!(" {wrapped}"); } else { - println!(" {}", Theme::dim().render("(no description)")); + println!(" {}", Theme::muted().render("(no description)")); } - println!(); - + // Discussions section let user_discussions: Vec<&MrDiscussionDetail> = mr .discussions .iter() @@ -843,13 +897,12 @@ pub fn print_show_mr(mr: &MrDetail) { .collect(); if user_discussions.is_empty() { - println!("{}", Theme::dim().render("Discussions: (none)")); + println!("\n {}", Theme::muted().render("No discussions")); } else { println!( "{}", - Theme::bold().render(&format!("Discussions ({}):", user_discussions.len())) + render::section_divider(&format!("Discussions ({})", user_discussions.len())) ); - println!(); for discussion in user_discussions { let user_notes: Vec<&MrNoteDetail> = @@ -861,22 +914,22 @@ pub fn print_show_mr(mr: &MrDetail) { } println!( - " {} ({}):", + " {} {}", Theme::info().render(&format!("@{}", first_note.author_username)), - format_date(first_note.created_at) + format_date(first_note.created_at), ); - let wrapped = wrap_text(&first_note.body, 72, " "); - println!(" {}", wrapped); + let wrapped = wrap_text(&first_note.body, 68, " "); + println!(" {wrapped}"); println!(); for reply in user_notes.iter().skip(1) { println!( - " {} ({}):", + " {} {}", Theme::info().render(&format!("@{}", reply.author_username)), - format_date(reply.created_at) + format_date(reply.created_at), ); - let wrapped = wrap_text(&reply.body, 68, " "); - println!(" {}", wrapped); + let wrapped = wrap_text(&reply.body, 66, " "); + println!(" {wrapped}"); println!(); } }