Skip to content

Commit e3ff710

Browse files
committed
feat(show): display deps with titles, symbols, and open-first sorting
deps and dependents in both CLI `brd show` and TUI detail pane now render as multi-line lists with status symbols, titles (truncated to 60 chars), and open/doing issues sorted before done/skip.
1 parent fdbb315 commit e3ff710

2 files changed

Lines changed: 173 additions & 84 deletions

File tree

src/commands/show.rs

Lines changed: 88 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,76 @@ use crate::repo::RepoPaths;
1414

1515
use super::{issue_to_json, load_all_issues, resolve_issue_id};
1616

17+
/// status symbol matching TUI conventions.
18+
fn status_symbol(status: &Status) -> &'static str {
19+
match status {
20+
Status::Open => "○",
21+
Status::Doing => "→",
22+
Status::Done => "✓",
23+
Status::Skip => "⊘",
24+
}
25+
}
26+
27+
/// whether a status counts as resolved (sorts to the bottom).
28+
fn is_resolved(status: &Status) -> bool {
29+
matches!(status, Status::Done | Status::Skip)
30+
}
31+
32+
/// truncate a string to at most `max` chars, appending "…" if truncated.
33+
fn truncate_title(title: &str, max: usize) -> String {
34+
if title.chars().count() <= max {
35+
title.to_string()
36+
} else {
37+
let mut s: String = title.chars().take(max - 1).collect();
38+
s.push('…');
39+
s
40+
}
41+
}
42+
43+
/// sort dep IDs so open/doing come first, done/skip last.
44+
fn sort_deps_open_first(ids: &[String], issues: &HashMap<String, Issue>) -> Vec<String> {
45+
let mut sorted = ids.to_vec();
46+
sorted.sort_by_key(|id| {
47+
issues
48+
.get(id)
49+
.map(|i| is_resolved(&i.status()) as u8)
50+
.unwrap_or(0)
51+
});
52+
sorted
53+
}
54+
55+
/// format a list of dep/dependent IDs as multi-line with symbols and titles.
56+
fn format_dep_lines(
57+
ids: &[String],
58+
issues: &HashMap<String, Issue>,
59+
no_color: bool,
60+
) -> Vec<String> {
61+
let sorted = sort_deps_open_first(ids, issues);
62+
sorted
63+
.iter()
64+
.map(|dep_id| {
65+
if let Some(dep) = issues.get(dep_id) {
66+
let status = dep.status();
67+
let sym = status_symbol(&status);
68+
let title = truncate_title(dep.title(), 60);
69+
let line = format!(" {} {} ({}) {}", sym, dep_id, status, title);
70+
if !no_color && is_resolved(&status) {
71+
format!(
72+
"{}{}{}",
73+
SetAttribute(Attribute::Dim),
74+
line,
75+
SetAttribute(Attribute::Reset)
76+
)
77+
} else {
78+
line
79+
}
80+
} else {
81+
format!(" ? {} (missing)", dep_id)
82+
}
83+
})
84+
.collect()
85+
}
86+
1787
fn format_show_output(
1888
issue: &Issue,
1989
issues: &HashMap<String, Issue>,
@@ -38,57 +108,18 @@ fn format_show_output(
38108
}
39109

40110
if !issue.deps().is_empty() {
41-
let deps_with_status: Vec<String> = issue
42-
.deps()
43-
.iter()
44-
.map(|dep_id| {
45-
if let Some(dep) = issues.get(dep_id) {
46-
let status = dep.status();
47-
let is_resolved = matches!(status, Status::Done | Status::Skip);
48-
if !no_color && is_resolved {
49-
format!(
50-
"{}{} ({}){}",
51-
SetAttribute(Attribute::Dim),
52-
dep_id,
53-
status,
54-
SetAttribute(Attribute::Reset)
55-
)
56-
} else {
57-
format!("{} ({})", dep_id, status)
58-
}
59-
} else {
60-
format!("{} (missing)", dep_id)
61-
}
62-
})
63-
.collect();
64-
let _ = writeln!(output, "Blocked by: {}", deps_with_status.join(", "));
111+
let _ = writeln!(output, "Blocked by:");
112+
for line in format_dep_lines(issue.deps(), issues, no_color) {
113+
let _ = writeln!(output, "{}", line);
114+
}
65115
}
66116

67117
let dependents = get_dependents(issue.id(), issues);
68118
if !dependents.is_empty() {
69-
let deps_with_status: Vec<String> = dependents
70-
.iter()
71-
.map(|dep_id| {
72-
if let Some(dep) = issues.get(dep_id) {
73-
let status = dep.status();
74-
let is_resolved = matches!(status, Status::Done | Status::Skip);
75-
if !no_color && is_resolved {
76-
format!(
77-
"{}{} ({}){}",
78-
SetAttribute(Attribute::Dim),
79-
dep_id,
80-
status,
81-
SetAttribute(Attribute::Reset)
82-
)
83-
} else {
84-
format!("{} ({})", dep_id, status)
85-
}
86-
} else {
87-
format!("{} (missing)", dep_id)
88-
}
89-
})
90-
.collect();
91-
let _ = writeln!(output, "Blocks: {}", deps_with_status.join(", "));
119+
let _ = writeln!(output, "Blocks:");
120+
for line in format_dep_lines(&dependents, issues, no_color) {
121+
let _ = writeln!(output, "{}", line);
122+
}
92123
}
93124

94125
if !issue.tags().is_empty() {
@@ -260,7 +291,9 @@ mod tests {
260291
assert!(output.contains("Priority: P1"));
261292
assert!(output.contains("Status: open"));
262293
assert!(output.contains("Type: meta"));
263-
assert!(output.contains("Blocked by: brd-aaaa (open), brd-missing (missing)"));
294+
assert!(output.contains("Blocked by:"));
295+
assert!(output.contains(" ○ brd-aaaa (open) dep issue"));
296+
assert!(output.contains(" ? brd-missing (missing)"));
264297
assert!(output.contains("Tags: visual, urgent"));
265298
assert!(output.contains("Owner: agent-one"));
266299
assert!(output.contains("Acceptance:"));
@@ -366,9 +399,13 @@ mod tests {
366399

367400
let output = format_show_output(&parent, &issues, false, true);
368401

369-
// dependents should show status in parentheses
402+
// dependents should show status symbol, id, status, and title — sorted open first
370403
assert!(output.contains("Blocks:"));
371-
assert!(output.contains("brd-child1 (open)"));
372-
assert!(output.contains("brd-child2 (done)"));
404+
assert!(output.contains(" ○ brd-child1 (open) open child"));
405+
assert!(output.contains(" ✓ brd-child2 (done) done child"));
406+
// open should appear before done
407+
let open_pos = output.find("brd-child1").unwrap();
408+
let done_pos = output.find("brd-child2").unwrap();
409+
assert!(open_pos < done_pos);
373410
}
374411
}

src/tui/ui.rs

Lines changed: 85 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1446,50 +1446,79 @@ fn build_detail_lines(app: &App, selected_dep: Option<usize>) -> Vec<Line<'stati
14461446
]));
14471447
}
14481448

1449-
// deps
1449+
// deps — sorted open/doing first, with titles inline
14501450
if !issue.deps().is_empty() {
14511451
lines.push(Line::from(""));
14521452
lines.push(Line::from(Span::styled(
14531453
"Blocked by:",
14541454
Style::default().fg(Color::DarkGray),
14551455
)));
1456-
for (idx, dep_id) in issue.deps().iter().enumerate() {
1457-
let is_selected = selected_dep == Some(idx);
1456+
1457+
// build sorted indices: open/doing first, done/skip last
1458+
let deps = issue.deps();
1459+
let mut dep_indices: Vec<usize> = (0..deps.len()).collect();
1460+
dep_indices.sort_by_key(|&i| {
1461+
app.issues
1462+
.get(&deps[i])
1463+
.map(|iss| matches!(iss.status(), Status::Done | Status::Skip) as u8)
1464+
.unwrap_or(0)
1465+
});
1466+
1467+
for (display_idx, &orig_idx) in dep_indices.iter().enumerate() {
1468+
let dep_id = &deps[orig_idx];
1469+
let is_selected = selected_dep == Some(display_idx);
14581470
// show number for selection (1-9) when selection enabled, otherwise bullet
14591471
let prefix = if selected_dep.is_some() {
1460-
if idx < 9 {
1461-
format!("{}", idx + 1)
1472+
if display_idx < 9 {
1473+
format!("{}", display_idx + 1)
14621474
} else {
14631475
"-".to_string()
14641476
}
14651477
} else {
14661478
" ".to_string()
14671479
};
14681480

1469-
let (symbol, status_text, base_color) = if let Some(dep_issue) = app.issues.get(dep_id)
1470-
{
1471-
match dep_issue.status() {
1472-
Status::Done => ("✓", "done", Color::Green),
1473-
Status::Skip => ("⊘", "skip", Color::DarkGray),
1474-
Status::Doing => ("→", "doing", Color::Yellow),
1475-
Status::Open => ("○", "open", Color::White),
1476-
}
1477-
} else {
1478-
("?", "missing", Color::Red)
1479-
};
1481+
let (symbol, status_text, base_color, title) =
1482+
if let Some(dep_issue) = app.issues.get(dep_id) {
1483+
let t = dep_issue.title();
1484+
let truncated = if t.chars().count() > 60 {
1485+
let mut s: String = t.chars().take(59).collect();
1486+
s.push('…');
1487+
s
1488+
} else {
1489+
t.to_string()
1490+
};
1491+
match dep_issue.status() {
1492+
Status::Done => ("✓", "done", Color::Green, truncated),
1493+
Status::Skip => ("⊘", "skip", Color::DarkGray, truncated),
1494+
Status::Doing => ("→", "doing", Color::Yellow, truncated),
1495+
Status::Open => ("○", "open", Color::White, truncated),
1496+
}
1497+
} else {
1498+
("?", "missing", Color::Red, String::new())
1499+
};
14801500

14811501
let mut style = Style::default().fg(base_color);
14821502
if is_selected {
14831503
style = style.add_modifier(Modifier::BOLD);
14841504
}
14851505

1486-
lines.push(Line::from(Span::styled(
1487-
format!("{} {} {} ({})", prefix, symbol, dep_id, status_text),
1488-
style,
1489-
)));
1506+
let text = if title.is_empty() {
1507+
format!("{} {} {} ({})", prefix, symbol, dep_id, status_text)
1508+
} else {
1509+
format!(
1510+
"{} {} {} ({}) {}",
1511+
prefix, symbol, dep_id, status_text, title
1512+
)
1513+
};
1514+
1515+
lines.push(Line::from(Span::styled(text, style)));
14901516
}
14911517

1492-
if let Some(dep_id) = selected_dep.and_then(|idx| issue.deps().get(idx)) {
1518+
if let Some(dep_id) = selected_dep
1519+
.and_then(|idx| dep_indices.get(idx))
1520+
.and_then(|&orig_idx| issue.deps().get(orig_idx))
1521+
{
14931522
lines.push(Line::from(""));
14941523
lines.push(Line::from(Span::styled(
14951524
"dependency preview:",
@@ -1523,29 +1552,52 @@ fn build_detail_lines(app: &App, selected_dep: Option<usize>) -> Vec<Line<'stati
15231552
}
15241553
}
15251554

1526-
// dependents (reverse deps - issues that depend on this one)
1555+
// dependents (reverse deps - issues that depend on this one) — sorted open/doing first
15271556
let dependents = get_dependents(issue.id(), &app.issues);
15281557
if !dependents.is_empty() {
15291558
lines.push(Line::from(""));
15301559
lines.push(Line::from(Span::styled(
15311560
"Blocks:",
15321561
Style::default().fg(Color::DarkGray),
15331562
)));
1534-
for dep_id in &dependents {
1535-
let (symbol, status_text, base_color) = if let Some(dep_issue) = app.issues.get(dep_id)
1536-
{
1537-
match dep_issue.status() {
1538-
Status::Done => ("✓", "done", Color::Green),
1539-
Status::Skip => ("⊘", "skip", Color::DarkGray),
1540-
Status::Doing => ("→", "doing", Color::Yellow),
1541-
Status::Open => ("○", "open", Color::White),
1542-
}
1563+
1564+
let mut sorted_dependents = dependents.clone();
1565+
sorted_dependents.sort_by_key(|id| {
1566+
app.issues
1567+
.get(id)
1568+
.map(|iss| matches!(iss.status(), Status::Done | Status::Skip) as u8)
1569+
.unwrap_or(0)
1570+
});
1571+
1572+
for dep_id in &sorted_dependents {
1573+
let (symbol, status_text, base_color, title) =
1574+
if let Some(dep_issue) = app.issues.get(dep_id) {
1575+
let t = dep_issue.title();
1576+
let truncated = if t.chars().count() > 60 {
1577+
let mut s: String = t.chars().take(59).collect();
1578+
s.push('…');
1579+
s
1580+
} else {
1581+
t.to_string()
1582+
};
1583+
match dep_issue.status() {
1584+
Status::Done => ("✓", "done", Color::Green, truncated),
1585+
Status::Skip => ("⊘", "skip", Color::DarkGray, truncated),
1586+
Status::Doing => ("→", "doing", Color::Yellow, truncated),
1587+
Status::Open => ("○", "open", Color::White, truncated),
1588+
}
1589+
} else {
1590+
("?", "missing", Color::Red, String::new())
1591+
};
1592+
1593+
let text = if title.is_empty() {
1594+
format!(" {} {} ({})", symbol, dep_id, status_text)
15431595
} else {
1544-
("?", "missing", Color::Red)
1596+
format!(" {} {} ({}) {}", symbol, dep_id, status_text, title)
15451597
};
15461598

15471599
lines.push(Line::from(Span::styled(
1548-
format!(" {} {} ({})", symbol, dep_id, status_text),
1600+
text,
15491601
Style::default().fg(base_color),
15501602
)));
15511603
}

0 commit comments

Comments
 (0)