diff --git a/src/cli/commands/explain.rs b/src/cli/commands/explain.rs index 5d405ce..1e763fd 100644 --- a/src/cli/commands/explain.rs +++ b/src/cli/commands/explain.rs @@ -3,7 +3,7 @@ use serde::Serialize; use crate::core::error::{LoreError, Result}; 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::seed::seed_timeline_direct; @@ -1127,43 +1127,73 @@ pub fn print_explain_json(result: &ExplainResult, elapsed_ms: u64) -> Result<()> } 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 - let type_label = match result.entity.entity_type.as_str() { - "issue" => "Issue", - "merge_request" => "MR", - _ => &result.entity.entity_type, + let (type_label, ref_style, ref_str) = match result.entity.entity_type.as_str() { + "issue" => ("Issue", Theme::issue_ref(), format!("#{}", result.entity.iid)), + "merge_request" => ("MR", Theme::mr_ref(), format!("!{}", result.entity.iid)), + _ => ( + 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!( - "{} {} #{} — {}", + "{} {} {} — {}", Icons::info(), Theme::bold().render(type_label), - result.entity.iid, + ref_style.render(&ref_str), Theme::bold().render(&result.entity.title) ); println!( - " Project: {} State: {} Author: {} Created: {}", - result.entity.project_path, - result.entity.state, - result.entity.author, - result.entity.created_at + " {} {} {} {}", + Theme::muted().render(&result.entity.project_path), + state_style.render(&result.entity.state), + Theme::username().render(&format!("@{}", result.entity.author)), + Theme::dim().render(&to_relative(&result.entity.created_at)), ); if !result.entity.assignees.is_empty() { - println!(" Assignees: {}", result.entity.assignees.join(", ")); + let styled: Vec = result + .entity + .assignees + .iter() + .map(|a| Theme::username().render(&format!("@{a}"))) + .collect(); + println!(" Assignees: {}", styled.join(", ")); } 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 { - println!(" URL: {url}"); + println!(" {}", Theme::dim().render(url)); } // Description if let Some(ref desc) = result.description_excerpt { - println!("\n{}", Theme::bold().render("Description")); + println!("{}", render::section_divider("Description")); for line in desc.lines() { - println!(" {line}"); + println!(" {line}"); } } @@ -1172,35 +1202,40 @@ pub fn print_explain(result: &ExplainResult) { && !decisions.is_empty() { println!( - "\n{} {}", - Icons::info(), - Theme::bold().render("Key Decisions") + "{}", + render::section_divider(&format!("Key Decisions ({})", decisions.len())) ); for d in decisions { println!( - " {} {} — {}", - Theme::muted().render(&d.timestamp), - Theme::bold().render(&d.actor), + " {} {} — {}", + Theme::muted().render(&to_date(&d.timestamp)), + Theme::username().render(&format!("@{}", d.actor)), d.action, ); for line in d.context_note.lines() { - println!(" {line}"); + println!(" {line}"); } } } // Activity if let Some(ref act) = result.activity { - println!("\n{}", Theme::bold().render("Activity")); + println!("{}", render::section_divider("Activity")); println!( - " {} state changes, {} label changes, {} notes", + " {} state changes, {} label changes, {} notes", act.state_changes, act.label_changes, act.notes ); 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 { - println!(" Last event: {last}"); + println!( + " Last event: {}", + Theme::dim().render(&to_relative(last)) + ); } } @@ -1209,29 +1244,23 @@ pub fn print_explain(result: &ExplainResult) { && !threads.is_empty() { println!( - "\n{} {} ({})", - Icons::warning(), - Theme::bold().render("Open Threads"), - threads.len() + "{}", + render::section_divider(&format!("Open Threads ({})", threads.len())) ); for t in threads { println!( - " {} by {} ({} notes, last: {})", - t.discussion_id, - t.started_by.as_deref().unwrap_or("unknown"), + " {} by {} ({} notes, last: {})", + Theme::dim().render(&t.discussion_id), + Theme::username() + .render(&format!("@{}", t.started_by.as_deref().unwrap_or("unknown"))), 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 { - let preview = if excerpt.len() > 100 { - let b = excerpt.floor_char_boundary(100); - format!("{}...", &excerpt[..b]) - } else { - excerpt.clone() - }; + let preview = render::truncate(excerpt, 100); // Show first line only in human output 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 && (!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 { + let mr_state = match mr.state.as_str() { + "merged" => Theme::state_merged(), + "closed" => Theme::state_closed(), + "opened" => Theme::state_opened(), + _ => Theme::dim(), + }; println!( - " {} MR !{} — {} [{}]", + " {} {} — {} {}", Icons::success(), - mr.iid, - mr.title, - mr.state + Theme::mr_ref().render(&format!("!{}", mr.iid)), + render::truncate(&mr.title, 60), + mr_state.render(&format!("[{}]", mr.state)) ); } for ri in &related.related_issues { @@ -1261,13 +1297,16 @@ pub fn print_explain(result: &ExplainResult) { } else { "->" }; + let (ref_style, ref_prefix) = match ri.entity_type.as_str() { + "issue" => (Theme::issue_ref(), "#"), + "merge_request" => (Theme::mr_ref(), "!"), + _ => (Theme::info(), "#"), + }; println!( - " {} {arrow} {} #{} — {}{state_str} ({})", - Icons::info(), - ri.entity_type, - ri.iid, - ri.title.as_deref().unwrap_or("(untitled)"), - ri.reference_type + " {arrow} {} {}{state_str} ({})", + ref_style.render(&format!("{ref_prefix}{}", ri.iid)), + render::truncate(ri.title.as_deref().unwrap_or("(untitled)"), 50), + Theme::dim().render(&ri.reference_type) ); } } @@ -1276,26 +1315,25 @@ pub fn print_explain(result: &ExplainResult) { if let Some(ref excerpt) = result.timeline_excerpt && !excerpt.events.is_empty() { - let truncation_note = if excerpt.truncated { + let title = if excerpt.truncated { format!( - " (showing {} of {})", + "Timeline (showing {} of {})", excerpt.events.len(), excerpt.total_events ) } else { - String::new() + format!("Timeline ({})", excerpt.total_events) }; - println!( - "\n{} {}{}", - Icons::info(), - Theme::bold().render("Timeline"), - truncation_note - ); + println!("{}", render::section_divider(&title)); 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!( - " {} {} {} {}", - Theme::muted().render(&e.timestamp), + " {} {} {} {}", + Theme::muted().render(&to_date(&e.timestamp)), e.event_type, actor_str, e.summary