refactor(cli): adopt flex-width rendering, remove data-layer truncation

Replace hardcoded truncation widths across CLI commands with
render::flex_width() calls that adapt to terminal size. Remove
server-side truncate_to_chars() in timeline collect/seed stages so
full text is preserved through the pipeline — truncation now happens
only at the presentation layer where terminal width is known.

Affected commands: explain, file-history, list (issues/mrs/notes),
me, timeline, who (active/expert/workload).
This commit is contained in:
teernisse
2026-03-13 11:01:17 -04:00
parent ef8a316372
commit 6d85474052
15 changed files with 59 additions and 66 deletions

View File

@@ -378,17 +378,10 @@ fn get_mr_assignees(conn: &Connection, mr_id: i64) -> Result<Vec<String>> {
// Description excerpt helper
// ---------------------------------------------------------------------------
fn truncate_description(desc: Option<&str>, max_len: usize) -> String {
fn truncate_description(desc: Option<&str>) -> String {
match desc {
None | Some("") => "(no description)".to_string(),
Some(s) => {
if s.len() <= max_len {
s.to_string()
} else {
let boundary = s.floor_char_boundary(max_len);
format!("{}...", &s[..boundary])
}
}
Some(s) => s.to_string(),
}
}
@@ -413,7 +406,7 @@ pub fn run_explain(conn: &Connection, params: &ExplainParams) -> Result<ExplainR
};
let description_excerpt = if should_include(&params.sections, "description") {
Some(truncate_description(description.as_deref(), 500))
Some(truncate_description(description.as_deref()))
} else {
None
};
@@ -537,8 +530,10 @@ const DECISION_WINDOW_MS: i64 = 60 * 60 * 1000;
/// Maximum length (in bytes, snapped to a char boundary) for the
/// `context_note` field in a `KeyDecision`.
#[allow(dead_code)]
const NOTE_TRUNCATE_LEN: usize = 500;
#[allow(dead_code)]
fn truncate_note(text: &str, max_len: usize) -> String {
if text.len() <= max_len {
text.to_string()
@@ -682,7 +677,7 @@ pub fn extract_key_decisions(
timestamp: ms_to_iso(event.created_at),
actor: event.actor.clone(),
action: event.description.clone(),
context_note: truncate_note(&note.body, NOTE_TRUNCATE_LEN),
context_note: note.body.clone(),
});
}
}
@@ -1257,7 +1252,7 @@ pub fn print_explain(result: &ExplainResult) {
Theme::dim().render(&to_relative(&t.last_note_at))
);
if let Some(ref excerpt) = t.first_note_excerpt {
let preview = render::truncate(excerpt, 100);
let preview = render::truncate(excerpt, render::flex_width(8, 30));
// Show first line only in human output
if let Some(line) = preview.lines().next() {
println!(" {}", Theme::muted().render(line));
@@ -1283,7 +1278,7 @@ pub fn print_explain(result: &ExplainResult) {
" {} {}{} {}",
Icons::success(),
Theme::mr_ref().render(&format!("!{}", mr.iid)),
render::truncate(&mr.title, 60),
render::truncate(&mr.title, render::flex_width(25, 20)),
mr_state.render(&format!("[{}]", mr.state))
);
}
@@ -1305,7 +1300,7 @@ pub fn print_explain(result: &ExplainResult) {
println!(
" {arrow} {} {}{state_str} ({})",
ref_style.render(&format!("{ref_prefix}{}", ri.iid)),
render::truncate(ri.title.as_deref().unwrap_or("(untitled)"), 50),
render::truncate(ri.title.as_deref().unwrap_or("(untitled)"), render::flex_width(30, 20)),
Theme::dim().render(&ri.reference_type)
);
}
@@ -1596,14 +1591,13 @@ mod tests {
#[test]
fn test_truncate_description() {
assert_eq!(truncate_description(None, 500), "(no description)");
assert_eq!(truncate_description(Some(""), 500), "(no description)");
assert_eq!(truncate_description(Some("short"), 500), "short");
assert_eq!(truncate_description(None), "(no description)");
assert_eq!(truncate_description(Some("")), "(no description)");
assert_eq!(truncate_description(Some("short")), "short");
let long = "a".repeat(600);
let truncated = truncate_description(Some(&long), 500);
assert!(truncated.ends_with("..."));
assert!(truncated.len() <= 504); // 500 + "..."
let result = truncate_description(Some(&long));
assert_eq!(result, long); // no truncation — full description preserved
}
// -----------------------------------------------------------------------