fix(explain): align human output with render module conventions
The explain command's human-mode output was hand-rolled with raw println! formatting that didn't use any of the shared render.rs infrastructure. This made it visually inconsistent with every other command (me, who, search, timeline). Changes to print_explain(): - Section headers now use render::section_divider() with counts, producing the same box-drawing divider lines as the me command - Entity refs use Theme::issue_ref()/mr_ref() color styling - Entity state uses Theme::state_opened/closed/merged() styling - Authors/usernames use Theme::username() with @ prefix - Project paths use Theme::muted() - Timestamps use format_relative_time() for recency fields (created, first/last event, last note) and format_date() for point-in-time fields (key decisions, timeline events), matching the conventions in me, who, and timeline respectively - Note excerpts use render::truncate() instead of manual byte slicing - Related entity titles are truncated via render::truncate() - Indentation aligned to 4-space content under section dividers Robot JSON output is unchanged -- it continues to use ms_to_iso() for all timestamp fields, consistent with the rest of the robot API.
This commit is contained in:
@@ -3,7 +3,7 @@ use serde::Serialize;
|
|||||||
|
|
||||||
use crate::core::error::{LoreError, Result};
|
use crate::core::error::{LoreError, Result};
|
||||||
use crate::core::project::resolve_project;
|
use crate::core::project::resolve_project;
|
||||||
use crate::core::time::ms_to_iso;
|
use crate::core::time::{iso_to_ms, ms_to_iso};
|
||||||
use crate::timeline::collect::collect_events;
|
use crate::timeline::collect::collect_events;
|
||||||
use crate::timeline::seed::seed_timeline_direct;
|
use crate::timeline::seed::seed_timeline_direct;
|
||||||
|
|
||||||
@@ -1127,41 +1127,71 @@ pub fn print_explain_json(result: &ExplainResult, elapsed_ms: u64) -> Result<()>
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn print_explain(result: &ExplainResult) {
|
pub fn print_explain(result: &ExplainResult) {
|
||||||
use crate::cli::render::{Icons, Theme};
|
use crate::cli::render::{self, Icons, Theme};
|
||||||
|
|
||||||
|
let to_relative = |iso: &str| -> String {
|
||||||
|
iso_to_ms(iso)
|
||||||
|
.map(render::format_relative_time)
|
||||||
|
.unwrap_or_else(|| iso.to_string())
|
||||||
|
};
|
||||||
|
let to_date = |iso: &str| -> String {
|
||||||
|
iso_to_ms(iso)
|
||||||
|
.map(render::format_date)
|
||||||
|
.unwrap_or_else(|| iso.to_string())
|
||||||
|
};
|
||||||
|
|
||||||
// Entity header
|
// Entity header
|
||||||
let type_label = match result.entity.entity_type.as_str() {
|
let (type_label, ref_style, ref_str) = match result.entity.entity_type.as_str() {
|
||||||
"issue" => "Issue",
|
"issue" => ("Issue", Theme::issue_ref(), format!("#{}", result.entity.iid)),
|
||||||
"merge_request" => "MR",
|
"merge_request" => ("MR", Theme::mr_ref(), format!("!{}", result.entity.iid)),
|
||||||
_ => &result.entity.entity_type,
|
_ => (
|
||||||
|
result.entity.entity_type.as_str(),
|
||||||
|
Theme::info(),
|
||||||
|
format!("#{}", result.entity.iid),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let state_style = match result.entity.state.as_str() {
|
||||||
|
"opened" => Theme::state_opened(),
|
||||||
|
"closed" => Theme::state_closed(),
|
||||||
|
"merged" => Theme::state_merged(),
|
||||||
|
_ => Theme::dim(),
|
||||||
};
|
};
|
||||||
println!(
|
println!(
|
||||||
"{} {} #{} — {}",
|
"{} {} {} — {}",
|
||||||
Icons::info(),
|
Icons::info(),
|
||||||
Theme::bold().render(type_label),
|
Theme::bold().render(type_label),
|
||||||
result.entity.iid,
|
ref_style.render(&ref_str),
|
||||||
Theme::bold().render(&result.entity.title)
|
Theme::bold().render(&result.entity.title)
|
||||||
);
|
);
|
||||||
println!(
|
println!(
|
||||||
" Project: {} State: {} Author: {} Created: {}",
|
" {} {} {} {}",
|
||||||
result.entity.project_path,
|
Theme::muted().render(&result.entity.project_path),
|
||||||
result.entity.state,
|
state_style.render(&result.entity.state),
|
||||||
result.entity.author,
|
Theme::username().render(&format!("@{}", result.entity.author)),
|
||||||
result.entity.created_at
|
Theme::dim().render(&to_relative(&result.entity.created_at)),
|
||||||
);
|
);
|
||||||
if !result.entity.assignees.is_empty() {
|
if !result.entity.assignees.is_empty() {
|
||||||
println!(" Assignees: {}", result.entity.assignees.join(", "));
|
let styled: Vec<String> = result
|
||||||
|
.entity
|
||||||
|
.assignees
|
||||||
|
.iter()
|
||||||
|
.map(|a| Theme::username().render(&format!("@{a}")))
|
||||||
|
.collect();
|
||||||
|
println!(" Assignees: {}", styled.join(", "));
|
||||||
}
|
}
|
||||||
if !result.entity.labels.is_empty() {
|
if !result.entity.labels.is_empty() {
|
||||||
println!(" Labels: {}", result.entity.labels.join(", "));
|
println!(
|
||||||
|
" Labels: {}",
|
||||||
|
Theme::dim().render(&result.entity.labels.join(", "))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if let Some(ref url) = result.entity.url {
|
if let Some(ref url) = result.entity.url {
|
||||||
println!(" URL: {url}");
|
println!(" {}", Theme::dim().render(url));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Description
|
// Description
|
||||||
if let Some(ref desc) = result.description_excerpt {
|
if let Some(ref desc) = result.description_excerpt {
|
||||||
println!("\n{}", Theme::bold().render("Description"));
|
println!("{}", render::section_divider("Description"));
|
||||||
for line in desc.lines() {
|
for line in desc.lines() {
|
||||||
println!(" {line}");
|
println!(" {line}");
|
||||||
}
|
}
|
||||||
@@ -1172,15 +1202,14 @@ pub fn print_explain(result: &ExplainResult) {
|
|||||||
&& !decisions.is_empty()
|
&& !decisions.is_empty()
|
||||||
{
|
{
|
||||||
println!(
|
println!(
|
||||||
"\n{} {}",
|
"{}",
|
||||||
Icons::info(),
|
render::section_divider(&format!("Key Decisions ({})", decisions.len()))
|
||||||
Theme::bold().render("Key Decisions")
|
|
||||||
);
|
);
|
||||||
for d in decisions {
|
for d in decisions {
|
||||||
println!(
|
println!(
|
||||||
" {} {} — {}",
|
" {} {} — {}",
|
||||||
Theme::muted().render(&d.timestamp),
|
Theme::muted().render(&to_date(&d.timestamp)),
|
||||||
Theme::bold().render(&d.actor),
|
Theme::username().render(&format!("@{}", d.actor)),
|
||||||
d.action,
|
d.action,
|
||||||
);
|
);
|
||||||
for line in d.context_note.lines() {
|
for line in d.context_note.lines() {
|
||||||
@@ -1191,16 +1220,22 @@ pub fn print_explain(result: &ExplainResult) {
|
|||||||
|
|
||||||
// Activity
|
// Activity
|
||||||
if let Some(ref act) = result.activity {
|
if let Some(ref act) = result.activity {
|
||||||
println!("\n{}", Theme::bold().render("Activity"));
|
println!("{}", render::section_divider("Activity"));
|
||||||
println!(
|
println!(
|
||||||
" {} state changes, {} label changes, {} notes",
|
" {} state changes, {} label changes, {} notes",
|
||||||
act.state_changes, act.label_changes, act.notes
|
act.state_changes, act.label_changes, act.notes
|
||||||
);
|
);
|
||||||
if let Some(ref first) = act.first_event {
|
if let Some(ref first) = act.first_event {
|
||||||
println!(" First event: {first}");
|
println!(
|
||||||
|
" First event: {}",
|
||||||
|
Theme::dim().render(&to_relative(first))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if let Some(ref last) = act.last_event {
|
if let Some(ref last) = act.last_event {
|
||||||
println!(" Last event: {last}");
|
println!(
|
||||||
|
" Last event: {}",
|
||||||
|
Theme::dim().render(&to_relative(last))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1209,26 +1244,20 @@ pub fn print_explain(result: &ExplainResult) {
|
|||||||
&& !threads.is_empty()
|
&& !threads.is_empty()
|
||||||
{
|
{
|
||||||
println!(
|
println!(
|
||||||
"\n{} {} ({})",
|
"{}",
|
||||||
Icons::warning(),
|
render::section_divider(&format!("Open Threads ({})", threads.len()))
|
||||||
Theme::bold().render("Open Threads"),
|
|
||||||
threads.len()
|
|
||||||
);
|
);
|
||||||
for t in threads {
|
for t in threads {
|
||||||
println!(
|
println!(
|
||||||
" {} by {} ({} notes, last: {})",
|
" {} by {} ({} notes, last: {})",
|
||||||
t.discussion_id,
|
Theme::dim().render(&t.discussion_id),
|
||||||
t.started_by.as_deref().unwrap_or("unknown"),
|
Theme::username()
|
||||||
|
.render(&format!("@{}", t.started_by.as_deref().unwrap_or("unknown"))),
|
||||||
t.note_count,
|
t.note_count,
|
||||||
t.last_note_at
|
Theme::dim().render(&to_relative(&t.last_note_at))
|
||||||
);
|
);
|
||||||
if let Some(ref excerpt) = t.first_note_excerpt {
|
if let Some(ref excerpt) = t.first_note_excerpt {
|
||||||
let preview = if excerpt.len() > 100 {
|
let preview = render::truncate(excerpt, 100);
|
||||||
let b = excerpt.floor_char_boundary(100);
|
|
||||||
format!("{}...", &excerpt[..b])
|
|
||||||
} else {
|
|
||||||
excerpt.clone()
|
|
||||||
};
|
|
||||||
// Show first line only in human output
|
// Show first line only in human output
|
||||||
if let Some(line) = preview.lines().next() {
|
if let Some(line) = preview.lines().next() {
|
||||||
println!(" {}", Theme::muted().render(line));
|
println!(" {}", Theme::muted().render(line));
|
||||||
@@ -1241,14 +1270,21 @@ pub fn print_explain(result: &ExplainResult) {
|
|||||||
if let Some(ref related) = result.related
|
if let Some(ref related) = result.related
|
||||||
&& (!related.closing_mrs.is_empty() || !related.related_issues.is_empty())
|
&& (!related.closing_mrs.is_empty() || !related.related_issues.is_empty())
|
||||||
{
|
{
|
||||||
println!("\n{}", Theme::bold().render("Related"));
|
let total = related.closing_mrs.len() + related.related_issues.len();
|
||||||
|
println!("{}", render::section_divider(&format!("Related ({total})")));
|
||||||
for mr in &related.closing_mrs {
|
for mr in &related.closing_mrs {
|
||||||
|
let mr_state = match mr.state.as_str() {
|
||||||
|
"merged" => Theme::state_merged(),
|
||||||
|
"closed" => Theme::state_closed(),
|
||||||
|
"opened" => Theme::state_opened(),
|
||||||
|
_ => Theme::dim(),
|
||||||
|
};
|
||||||
println!(
|
println!(
|
||||||
" {} MR !{} — {} [{}]",
|
" {} {} — {} {}",
|
||||||
Icons::success(),
|
Icons::success(),
|
||||||
mr.iid,
|
Theme::mr_ref().render(&format!("!{}", mr.iid)),
|
||||||
mr.title,
|
render::truncate(&mr.title, 60),
|
||||||
mr.state
|
mr_state.render(&format!("[{}]", mr.state))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for ri in &related.related_issues {
|
for ri in &related.related_issues {
|
||||||
@@ -1261,13 +1297,16 @@ pub fn print_explain(result: &ExplainResult) {
|
|||||||
} else {
|
} else {
|
||||||
"->"
|
"->"
|
||||||
};
|
};
|
||||||
|
let (ref_style, ref_prefix) = match ri.entity_type.as_str() {
|
||||||
|
"issue" => (Theme::issue_ref(), "#"),
|
||||||
|
"merge_request" => (Theme::mr_ref(), "!"),
|
||||||
|
_ => (Theme::info(), "#"),
|
||||||
|
};
|
||||||
println!(
|
println!(
|
||||||
" {} {arrow} {} #{} — {}{state_str} ({})",
|
" {arrow} {} {}{state_str} ({})",
|
||||||
Icons::info(),
|
ref_style.render(&format!("{ref_prefix}{}", ri.iid)),
|
||||||
ri.entity_type,
|
render::truncate(ri.title.as_deref().unwrap_or("(untitled)"), 50),
|
||||||
ri.iid,
|
Theme::dim().render(&ri.reference_type)
|
||||||
ri.title.as_deref().unwrap_or("(untitled)"),
|
|
||||||
ri.reference_type
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1276,26 +1315,25 @@ pub fn print_explain(result: &ExplainResult) {
|
|||||||
if let Some(ref excerpt) = result.timeline_excerpt
|
if let Some(ref excerpt) = result.timeline_excerpt
|
||||||
&& !excerpt.events.is_empty()
|
&& !excerpt.events.is_empty()
|
||||||
{
|
{
|
||||||
let truncation_note = if excerpt.truncated {
|
let title = if excerpt.truncated {
|
||||||
format!(
|
format!(
|
||||||
" (showing {} of {})",
|
"Timeline (showing {} of {})",
|
||||||
excerpt.events.len(),
|
excerpt.events.len(),
|
||||||
excerpt.total_events
|
excerpt.total_events
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
format!("Timeline ({})", excerpt.total_events)
|
||||||
};
|
};
|
||||||
println!(
|
println!("{}", render::section_divider(&title));
|
||||||
"\n{} {}{}",
|
|
||||||
Icons::info(),
|
|
||||||
Theme::bold().render("Timeline"),
|
|
||||||
truncation_note
|
|
||||||
);
|
|
||||||
for e in &excerpt.events {
|
for e in &excerpt.events {
|
||||||
let actor_str = e.actor.as_deref().unwrap_or("");
|
let actor_str = e
|
||||||
|
.actor
|
||||||
|
.as_deref()
|
||||||
|
.map(|a| Theme::username().render(&format!("@{a}")))
|
||||||
|
.unwrap_or_default();
|
||||||
println!(
|
println!(
|
||||||
" {} {} {} {}",
|
" {} {} {} {}",
|
||||||
Theme::muted().render(&e.timestamp),
|
Theme::muted().render(&to_date(&e.timestamp)),
|
||||||
e.event_type,
|
e.event_type,
|
||||||
actor_str,
|
actor_str,
|
||||||
e.summary
|
e.summary
|
||||||
|
|||||||
Reference in New Issue
Block a user