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 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::<Vec<_>>()
.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::<Vec<_>>()
.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::<Vec<_>>()
.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!();
}
}