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:
committed by
teernisse
parent
4b372dfb38
commit
d0744039ef
@@ -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);
|
||||
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 {
|
||||
println!(" {}", Theme::error().bold().render("CONFIDENTIAL"));
|
||||
}
|
||||
|
||||
if let Some(status) = &issue.status_name {
|
||||
println!(
|
||||
"Status: {}",
|
||||
render::style_with_hex(status, issue.status_color.as_deref())
|
||||
);
|
||||
}
|
||||
|
||||
println!("Author: @{}", issue.author_username);
|
||||
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!();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user