#[allow(clippy::wildcard_imports)]
use super::*;
#[cfg(test)]
pub(super) fn no_chain() -> crate::relationships::DependencyChain {
crate::relationships::DependencyChain {
root: 0,
paths: vec![],
truncated: false,
}
}
pub(super) fn health_line(label: &str, value: &str) -> Line<'static> {
Line::from(vec![
Span::styled(format!("{label}: "), Style::default().fg(Color::DarkGray)),
Span::raw(value.to_string()),
])
}
pub(super) const TIME_COLUMNS: [&str; 5] = ["ENTRY", "MINUTES", "DATE", "AUTHOR", "NOTE"];
pub(super) const TIME_WIDTHS: [usize; 5] = [18, 7, 10, 14, 0];
#[allow(clippy::too_many_lines)]
pub fn render(detail: &DetailState, f: &mut Frame, area: Rect, theme: &crate::ui::color::Theme) {
let mut lines: Vec<Line<'static>> = Vec::new();
let mut files_line_index: Option<usize> = None;
let fields: Vec<(&str, String)> = vec![
("number", detail.issue.number.to_string()),
("title", detail.issue.title.clone()),
("status", detail.issue.status.to_string()),
("wip", detail.wip.clone().unwrap_or_default()),
(
"category",
detail
.category
.as_ref()
.map(|(label, _)| label.clone())
.unwrap_or_else(|| "(unmapped)".to_string()),
),
("priority", detail.issue.priority.to_string()),
("created", detail.issue.created.clone()),
("updated", detail.issue.updated.clone()),
];
for (key, value) in &fields {
lines.push(Line::from(vec![
Span::styled(format!("{key}: "), Style::default().fg(Color::DarkGray)),
Span::raw(value.clone()),
]));
}
if let Some(health) = &detail.health {
let mut layer = format!(
"{} ({}, {})",
health.state,
health.source,
health
.recorded_policy_version
.as_deref()
.unwrap_or("no policy version recorded")
);
if let Some(recorded) = &health.recorded {
if recorded != &health.state {
layer.push_str(&format!(" recorded `{recorded}`"));
}
}
lines.push(health_line("health", &layer));
if let Some(derived) = &health.rollup {
let contributors: Vec<String> = derived
.contributors
.iter()
.map(|contributor| format!("#{} {}", contributor.number, contributor.state))
.collect();
lines.push(health_line(
"health derived",
&format!(
"{} ({}, {} contributor{}: {})",
derived.state,
derived.policy_version,
derived.contributors.len(),
if derived.contributors.len() == 1 {
""
} else {
"s"
},
if contributors.is_empty() {
"nothing contributes yet".to_string()
} else {
contributors.join(", ")
}
),
));
}
lines.push(health_line(
"health history",
&format!(
"{} entr{}",
health.events.len(),
if health.events.len() == 1 { "y" } else { "ies" }
),
));
}
for diagnostic in &detail.health_diagnostics {
lines.push(health_line(
"health diagnostic",
&format!("{}: {}", diagnostic.kind.as_str(), diagnostic.message),
));
if let Some(remedy) = &diagnostic.remedy {
lines.push(health_line(" remedy", remedy));
}
}
if let Some(rendered) = render_assignee(detail.issue.assignee.as_deref()) {
lines.push(Line::from(vec![
Span::styled("assignee: ", Style::default().fg(Color::DarkGray)),
Span::raw(rendered),
]));
}
if let Some(estimate) = &detail.issue.estimate {
lines.push(Line::from(vec![
Span::styled("estimate: ", Style::default().fg(Color::DarkGray)),
Span::raw(estimate.clone()),
]));
}
if !detail.capacity.is_empty() {
lines.push(Line::from(Span::styled("", Style::default())));
lines.push(Line::from(Span::styled(
"capacity:",
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)));
for view in &detail.capacity {
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(
view.scope.clone(),
Style::default().add_modifier(Modifier::BOLD),
),
]));
lines.push(health_line(" this issue", &view.issue_line));
for (key, value) in &view.fields {
lines.push(health_line(&format!(" {key}"), value));
}
if !view.diagnostics.is_empty() {
lines.push(health_line(
" findings",
&view.diagnostics.len().to_string(),
));
for diagnostic in &view.diagnostics {
for line in diagnostic.lines() {
lines.push(health_line(" ", line));
}
}
}
}
}
{
let filter =
crate::time_tracking::filters::TimeFilter::new().for_issue(detail.issue.number);
let snapshot =
crate::time_tracking::aggregate::TimeSnapshot::of(std::slice::from_ref(&detail.issue));
let total = snapshot.total(&filter);
lines.push(Line::from(Span::styled("", Style::default())));
lines.push(Line::from(Span::styled(
"time:",
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)));
lines.push(health_line(" state", total.state().as_str()));
lines.push(health_line(" total", &dash_number(total.total_minutes)));
lines.push(health_line(" entries", &total.entry_count.to_string()));
if total.is_partial() {
lines.push(health_line(
" malformed",
&total.malformed_count.to_string(),
));
lines.push(health_line(" undated", &total.undated_count.to_string()));
}
let deleted = snapshot.deletions_on(detail.issue.number).len();
let corrected = snapshot.corrections_on(detail.issue.number);
if deleted > 0 || corrected > 0 {
lines.push(health_line(" history deleted", &deleted.to_string()));
lines.push(health_line(" history corrected", &corrected.to_string()));
}
let rows: Vec<&crate::time_tracking::aggregate::IndexedEntry> = snapshot
.entries()
.iter()
.filter(|indexed| filter.selects(indexed.issue, &indexed.entry))
.collect();
if !rows.is_empty() {
lines.push(Line::from(Span::styled(
format!(" {}", time_entry_heading()),
Style::default().fg(Color::DarkGray),
)));
for indexed in rows {
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::raw(time_entry_row(&indexed.entry)),
]));
}
}
for finding in &total.diagnostics {
lines.push(health_line(
" finding",
&format!("{}: {}", finding.kind.as_str(), finding.reason),
));
lines.push(health_line(" remedy", &finding.remedy));
}
}
if let Some(commit) = &detail.issue.commit {
lines.push(Line::from(vec![
Span::styled("commit: ", Style::default().fg(Color::DarkGray)),
Span::raw(commit.clone()),
]));
}
if !detail.issue.labels.is_empty() {
let mut spans = vec![Span::styled(
"labels: ",
Style::default().fg(Color::DarkGray),
)];
for (index, label) in detail.issue.labels.iter().enumerate() {
if index > 0 {
spans.push(Span::raw(", "));
}
let style = theme
.tag(label)
.map_or_else(Style::default, |color| Style::default().fg(color));
spans.push(Span::styled(label.clone(), style));
}
lines.push(Line::from(spans));
}
if !detail.related.is_empty() {
let accent = Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD);
lines.push(Line::from(vec![
Span::styled("related: ", accent),
Span::styled(detail.related.len().to_string(), accent),
]));
let cursor = detail.related_cursor;
for (index, entry) in detail.related.iter().enumerate() {
let marker = if index == cursor {
Span::styled(" > ", Style::default().fg(Color::Cyan))
} else {
Span::styled(" ", Style::default())
};
lines.push(Line::from(vec![
marker,
Span::styled(entry.display(), Style::default().fg(Color::Magenta)),
]));
}
}
if !detail.relationships.is_empty() {
let cursor = detail.rel_cursor;
let mut spans: Vec<Span<'static>> = vec![Span::styled(
"links: ",
Style::default().fg(Color::DarkGray),
)];
for (index, rel) in detail.relationships.iter().enumerate() {
if index > 0 {
spans.push(Span::raw(", "));
}
if index == cursor {
spans.push(Span::styled("> ", Style::default().fg(Color::Cyan)));
}
spans.push(Span::raw(rel.display.clone()));
}
lines.push(Line::from(spans));
}
if detail.chain.paths.is_empty() {
lines.push(Line::from(vec![
Span::styled("chain: ", Style::default().fg(Color::DarkGray)),
Span::styled("none", Style::default().fg(Color::DarkGray)),
]));
} else {
for (depth, path) in detail.chain.paths.iter().enumerate() {
let rendered: Vec<String> = path
.iter()
.map(|node| {
let title = node.title.trim();
if title.is_empty() {
format!("#{}", node.number)
} else {
format!("#{} {}", node.number, title)
}
})
.collect();
let label = if depth == 0 { "chain: " } else { " " };
lines.push(Line::from(vec![
Span::styled(label, Style::default().fg(Color::DarkGray)),
Span::raw(rendered.join(" -> ")),
]));
}
}
match &detail.blocked {
Some(reason) => lines.push(Line::from(vec![
Span::styled("blocked: ", Style::default().fg(Color::Red)),
Span::styled(
reason.clone(),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
])),
None => lines.push(Line::from(vec![
Span::styled("blocked: ", Style::default().fg(Color::DarkGray)),
Span::styled("not blocked", Style::default().fg(Color::DarkGray)),
])),
}
lines.push(Line::from(Span::styled("", Style::default())));
lines.push(Line::from(Span::styled(
"hierarchy:",
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(vec![
Span::styled(" parent: ", Style::default().fg(Color::DarkGray)),
Span::raw(crate::commands::parent::parent_text(&detail.hierarchy)),
]));
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::raw(detail.hierarchy.child_count_line()),
]));
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::raw(detail.hierarchy.completion_line()),
]));
for child in &detail.hierarchy.children {
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::raw(child.display.clone()),
]));
}
if !detail.hierarchy.has_children() {
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::raw(crate::commands::parent::no_children_text(
detail.hierarchy.number,
)),
]));
}
if !detail.epics.is_empty() {
lines.push(Line::from(Span::styled("", Style::default())));
lines.push(Line::from(Span::styled(
"epics:",
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)));
for entry in &detail.epics {
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::raw(entry.heading.clone()),
]));
for line in &entry.detail {
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::raw(line.clone()),
]));
}
}
lines.push(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(
"members open directly from the epic view — no epic layer",
Style::default().fg(Color::DarkGray),
),
]));
}
if detail.chain.truncated {
lines.push(Line::from(Span::styled(
format!(
"note: chain display truncated at {} nodes; the chain may continue",
crate::relationships::MAX_CHAIN_NODES
),
Style::default().fg(Color::Yellow),
)));
}
if !detail.issue.files.is_empty() {
lines.push(Line::from(vec![
Span::styled("files: ", Style::default().fg(Color::DarkGray)),
Span::raw(detail.issue.files.join(", ")),
]));
files_line_index = Some(lines.len() - 1);
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"---",
Style::default().fg(Color::DarkGray),
)));
lines.push(Line::from(""));
let body_start = lines.len();
detail
.body_start
.set(u16::try_from(body_start).unwrap_or(u16::MAX));
let mention_token = detail
.mention_body
.as_deref()
.map(|body| format!("@{body}"));
let mut mention_line: Option<usize> = None;
let body_width = usize::from(area.width.saturating_sub(2).max(1));
let renderer = daat_locus_md::markdown::MarkdownRenderer::new(body_width);
let blocks = renderer.parse(&detail.issue.body);
let blocks: Vec<daat_locus_md::markdown::MarkdownBlock> = blocks
.into_iter()
.map(|block| match block {
daat_locus_md::markdown::MarkdownBlock::CodeBlock { lang, code, .. }
if lang.eq_ignore_ascii_case("mermaid") =>
{
let rendered = mermaid_text::render_with_width(&code, Some(body_width))
.unwrap_or_else(|_| code.clone());
daat_locus_md::markdown::MarkdownBlock::CodeBlock {
lang: String::new(),
code: rendered,
header_override: Some("╭─ mermaid".to_string()),
footer_override: Some("╰─".to_string()),
prefix_override: Some("│ ".to_string()),
}
}
other => other,
})
.collect();
for line in renderer.render(&blocks, &daat_locus_md::ThemeConfig::default()) {
let index = lines.len();
if mention_line.is_none() {
if let Some(token) = &mention_token {
if line_contains(&line, token) {
mention_line = Some(index);
}
}
}
if mention_line == Some(index) {
lines.push(highlight_mention_line(&line));
} else {
lines.push(line);
}
}
let content_lines = lines.len();
let title = if detail.read_only {
"Detail [read-only] [Esc=back]"
} else {
"Detail [Esc=back e=edit A=agent]"
};
let viewport = area.height.saturating_sub(2); detail.viewport.set(viewport);
let max_scroll = u16::try_from(content_lines)
.unwrap_or(u16::MAX)
.saturating_sub(viewport);
detail.max_scroll.set(max_scroll);
if detail.focus_pending.replace(false) {
if let Some(target) = mention_line {
detail
.scroll_offset
.set(u16::try_from(target).unwrap_or(u16::MAX).min(max_scroll));
}
}
let para = Paragraph::new(lines)
.scroll((detail.scroll_offset.get(), 0))
.block(Block::default().borders(Borders::ALL).title(title));
f.render_widget(para, area);
if let Some(idx) = files_line_index {
inject_files_hyperlinks(
f.buffer_mut(),
area,
idx,
detail.scroll_offset.get(),
&detail.issue.files,
);
}
}
pub fn render_assignee(assignee: Option<&str>) -> Option<String> {
let raw = assignee?;
if raw.contains('<') && raw.contains('>') {
return Some(raw.to_string());
}
if raw.starts_with('@') {
return Some(raw.to_string());
}
Some(format!("@{raw}"))
}
pub fn hyperlink(uri: &str, label: &str) -> String {
format!("{}{}{}", osc8_start(uri), label, osc8_end())
}
pub(super) fn osc8_start(uri: &str) -> String {
format!("\u{1b}]8;;{uri}\u{1b}\\")
}
pub(super) fn osc8_end() -> String {
"\u{1b}]8;;\u{1b}\\".to_string()
}
pub(super) fn file_uri(path: &str) -> String {
if path.starts_with("file://") {
return path.to_string();
}
let abs = std::path::absolute(path).unwrap_or_else(|_| path.into());
format!("file://{}", abs.to_string_lossy())
}
pub(super) fn inject_files_hyperlinks(
buf: &mut Buffer,
area: Rect,
line_index: usize,
scroll_offset: u16,
files: &[String],
) {
let inner_top = area.top() + 1;
let scrolled = line_index as i32 - scroll_offset as i32;
if scrolled < 0 {
return;
}
let y0 = inner_top + scrolled as u16;
if y0 >= area.bottom() {
return;
}
let (x0, inner_right) = (area.left() + 1, area.right().saturating_sub(1));
let mut x = x0 + "files: ".len() as u16;
for entry in files {
if entry.is_empty() {
continue;
}
let len = entry.chars().count() as u16;
if x + len > inner_right {
break;
}
let uri = file_uri(entry);
let first = &mut buf[(x, y0)];
first.set_symbol(&format!("{}{}", osc8_start(&uri), first.symbol()));
first.set_diff_option(CellDiffOption::ForcedWidth(
NonZeroU16::new(1).expect("one is non-zero"),
));
let last = &mut buf[(x + len - 1, y0)];
last.set_symbol(&format!("{}{}", last.symbol(), osc8_end()));
last.set_diff_option(CellDiffOption::ForcedWidth(
NonZeroU16::new(1).expect("one is non-zero"),
));
x += len + 2; }
}
fn line_contains(line: &Line<'_>, needle: &str) -> bool {
line.spans.iter().any(|span| span.content.contains(needle))
}
fn highlight_mention_line(line: &Line<'static>) -> Line<'static> {
let style = Style::default()
.bg(Color::Yellow)
.fg(Color::Black)
.add_modifier(Modifier::BOLD);
Line::from(
line.spans
.iter()
.map(|span| span.clone().patch_style(style))
.collect::<Vec<_>>(),
)
}