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 <noreply@anthropic.com>
This commit is contained in:
Taylor Eernisse
2026-02-14 10:02:37 -05:00
committed by teernisse
parent 4b372dfb38
commit d0744039ef

View File

@@ -1,4 +1,4 @@
use crate::cli::render::{self, Theme}; use crate::cli::render::{self, Icons, Theme};
use rusqlite::Connection; use rusqlite::Connection;
use serde::Serialize; use serde::Serialize;
@@ -614,33 +614,47 @@ fn wrap_text(text: &str, width: usize, indent: &str) -> String {
} }
pub fn print_show_issue(issue: &IssueDetail) { pub fn print_show_issue(issue: &IssueDetail) {
let header = format!("Issue #{}: {}", issue.iid, issue.title); // Title line
println!("{}", Theme::bold().render(&header)); println!(
println!("{}", "\u{2501}".repeat(header.len().min(80))); " Issue #{}: {}",
println!(); issue.iid,
Theme::bold().render(&issue.title),
);
println!("Ref: {}", Theme::dim().render(&issue.references_full)); // Details section
println!("Project: {}", Theme::info().render(&issue.project_path)); println!("{}", render::section_divider("Details"));
let state_styled = if issue.state == "opened" { println!(
Theme::success().render(&issue.state) " 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 { } else {
Theme::dim().render(&issue.state) (Icons::issue_closed(), Theme::dim())
}; };
println!("State: {}", state_styled); println!(
" State {}",
state_style.render(&format!("{icon} {}", issue.state))
);
if let Some(status) = &issue.status_name {
println!(
" Status {}",
render::style_with_hex(status, issue.status_color.as_deref())
);
}
if issue.confidential { if issue.confidential {
println!(" {}", Theme::error().bold().render("CONFIDENTIAL")); println!(" {}", Theme::error().bold().render("CONFIDENTIAL"));
} }
if let Some(status) = &issue.status_name { println!(" Author @{}", issue.author_username);
println!(
"Status: {}",
render::style_with_hex(status, issue.status_color.as_deref())
);
}
println!("Author: @{}", issue.author_username);
if !issue.assignees.is_empty() { if !issue.assignees.is_empty() {
let label = if issue.assignees.len() > 1 { let label = if issue.assignees.len() > 1 {
@@ -649,69 +663,82 @@ pub fn print_show_issue(issue: &IssueDetail) {
"Assignee" "Assignee"
}; };
println!( println!(
"{}:{} {}", " {}{} {}",
label, label,
" ".repeat(10 - label.len()), " ".repeat(12 - label.len()),
issue issue
.assignees .assignees
.iter() .iter()
.map(|a| format!("@{}", a)) .map(|a| format!("@{a}"))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", ") .join(", ")
); );
} }
println!("Created: {}", format_date(issue.created_at)); println!(
println!("Updated: {}", format_date(issue.updated_at)); " 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 { if let Some(closed_at) = &issue.closed_at {
println!("Closed: {}", closed_at); println!(" Closed {closed_at}");
} }
if let Some(due) = &issue.due_date { if let Some(due) = &issue.due_date {
println!("Due: {}", due); println!(" Due {due}");
} }
if let Some(ms) = &issue.milestone { if let Some(ms) = &issue.milestone {
println!("Milestone: {}", ms); println!(" Milestone {ms}");
} }
if issue.labels.is_empty() { if !issue.labels.is_empty() {
println!("Labels: {}", Theme::dim().render("(none)")); println!(
} else { " Labels {}",
println!("Labels: {}", issue.labels.join(", ")); render::format_labels_bare(&issue.labels, issue.labels.len())
} );
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 let Some(url) = &issue.web_url { 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 { if let Some(desc) = &issue.description {
let wrapped = wrap_text(desc, 76, " "); let wrapped = wrap_text(desc, 72, " ");
println!(" {}", wrapped); println!(" {wrapped}");
} else { } else {
println!(" {}", Theme::dim().render("(no description)")); println!(" {}", Theme::muted().render("(no description)"));
} }
println!(); // Discussions section
let user_discussions: Vec<&DiscussionDetail> = issue let user_discussions: Vec<&DiscussionDetail> = issue
.discussions .discussions
.iter() .iter()
@@ -719,13 +746,12 @@ pub fn print_show_issue(issue: &IssueDetail) {
.collect(); .collect();
if user_discussions.is_empty() { if user_discussions.is_empty() {
println!("{}", Theme::dim().render("Discussions: (none)")); println!("\n {}", Theme::muted().render("No discussions"));
} else { } else {
println!( println!(
"{}", "{}",
Theme::bold().render(&format!("Discussions ({}):", user_discussions.len())) render::section_divider(&format!("Discussions ({})", user_discussions.len()))
); );
println!();
for discussion in user_discussions { for discussion in user_discussions {
let user_notes: Vec<&NoteDetail> = let user_notes: Vec<&NoteDetail> =
@@ -733,22 +759,22 @@ pub fn print_show_issue(issue: &IssueDetail) {
if let Some(first_note) = user_notes.first() { if let Some(first_note) = user_notes.first() {
println!( println!(
" {} ({}):", " {} {}",
Theme::info().render(&format!("@{}", first_note.author_username)), 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, " "); let wrapped = wrap_text(&first_note.body, 68, " ");
println!(" {}", wrapped); println!(" {wrapped}");
println!(); println!();
for reply in user_notes.iter().skip(1) { for reply in user_notes.iter().skip(1) {
println!( println!(
" {} ({}):", " {} {}",
Theme::info().render(&format!("@{}", reply.author_username)), Theme::info().render(&format!("@{}", reply.author_username)),
format_date(reply.created_at) format_date(reply.created_at),
); );
let wrapped = wrap_text(&reply.body, 68, " "); let wrapped = wrap_text(&reply.body, 66, " ");
println!(" {}", wrapped); println!(" {wrapped}");
println!(); println!();
} }
} }
@@ -757,36 +783,49 @@ pub fn print_show_issue(issue: &IssueDetail) {
} }
pub fn print_show_mr(mr: &MrDetail) { pub fn print_show_mr(mr: &MrDetail) {
let draft_prefix = if mr.draft { "[Draft] " } else { "" }; // Title line
let header = format!("MR !{}: {}{}", mr.iid, draft_prefix, mr.title); let draft_prefix = if mr.draft {
println!("{}", Theme::bold().render(&header)); format!("{} ", Icons::mr_draft())
println!("{}", "\u{2501}".repeat(header.len().min(80))); } else {
println!(); String::new()
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),
}; };
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!( println!(
"Branches: {} -> {}", " Branches {} -> {}",
Theme::info().render(&mr.source_branch), Theme::info().render(&mr.source_branch),
Theme::warning().render(&mr.target_branch) Theme::warning().render(&mr.target_branch)
); );
println!("Author: @{}", mr.author_username); println!(" Author @{}", mr.author_username);
if !mr.assignees.is_empty() { if !mr.assignees.is_empty() {
println!( println!(
"Assignees: {}", " Assignees {}",
mr.assignees mr.assignees
.iter() .iter()
.map(|a| format!("@{}", a)) .map(|a| format!("@{a}"))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", ") .join(", ")
); );
@@ -794,48 +833,63 @@ pub fn print_show_mr(mr: &MrDetail) {
if !mr.reviewers.is_empty() { if !mr.reviewers.is_empty() {
println!( println!(
"Reviewers: {}", " Reviewers {}",
mr.reviewers mr.reviewers
.iter() .iter()
.map(|r| format!("@{}", r)) .map(|r| format!("@{r}"))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", ") .join(", ")
); );
} }
println!("Created: {}", format_date(mr.created_at)); println!(
println!("Updated: {}", format_date(mr.updated_at)); " 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 { 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 { 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() { if !mr.labels.is_empty() {
println!("Labels: {}", Theme::dim().render("(none)")); println!(
} else { " Labels {}",
println!("Labels: {}", mr.labels.join(", ")); render::format_labels_bare(&mr.labels, mr.labels.len())
);
} }
if let Some(url) = &mr.web_url { if let Some(url) = &mr.web_url {
println!("URL: {}", Theme::dim().render(url)); println!(" URL {}", Theme::muted().render(url));
} }
println!(); // Description section
println!("{}", render::section_divider("Description"));
println!("{}", Theme::bold().render("Description:"));
if let Some(desc) = &mr.description { if let Some(desc) = &mr.description {
let wrapped = wrap_text(desc, 76, " "); let wrapped = wrap_text(desc, 72, " ");
println!(" {}", wrapped); println!(" {wrapped}");
} else { } else {
println!(" {}", Theme::dim().render("(no description)")); println!(" {}", Theme::muted().render("(no description)"));
} }
println!(); // Discussions section
let user_discussions: Vec<&MrDiscussionDetail> = mr let user_discussions: Vec<&MrDiscussionDetail> = mr
.discussions .discussions
.iter() .iter()
@@ -843,13 +897,12 @@ pub fn print_show_mr(mr: &MrDetail) {
.collect(); .collect();
if user_discussions.is_empty() { if user_discussions.is_empty() {
println!("{}", Theme::dim().render("Discussions: (none)")); println!("\n {}", Theme::muted().render("No discussions"));
} else { } else {
println!( println!(
"{}", "{}",
Theme::bold().render(&format!("Discussions ({}):", user_discussions.len())) render::section_divider(&format!("Discussions ({})", user_discussions.len()))
); );
println!();
for discussion in user_discussions { for discussion in user_discussions {
let user_notes: Vec<&MrNoteDetail> = let user_notes: Vec<&MrNoteDetail> =
@@ -861,22 +914,22 @@ pub fn print_show_mr(mr: &MrDetail) {
} }
println!( println!(
" {} ({}):", " {} {}",
Theme::info().render(&format!("@{}", first_note.author_username)), 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, " "); let wrapped = wrap_text(&first_note.body, 68, " ");
println!(" {}", wrapped); println!(" {wrapped}");
println!(); println!();
for reply in user_notes.iter().skip(1) { for reply in user_notes.iter().skip(1) {
println!( println!(
" {} ({}):", " {} {}",
Theme::info().render(&format!("@{}", reply.author_username)), Theme::info().render(&format!("@{}", reply.author_username)),
format_date(reply.created_at) format_date(reply.created_at),
); );
let wrapped = wrap_text(&reply.body, 68, " "); let wrapped = wrap_text(&reply.body, 66, " ");
println!(" {}", wrapped); println!(" {wrapped}");
println!(); println!();
} }
} }