use ratatui::Frame;
use ratatui::buffer::{Buffer, CellDiffOption};
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use crate::model::Issue;
use std::num::NonZeroU16;
#[derive(Clone)]
pub struct DetailState {
pub issue: Issue,
pub wip: Option<String>,
pub category: Option<(String, Option<crate::workflow::ColorHint>)>,
pub relationships: Vec<crate::relationships::Relationship>,
pub chain: crate::relationships::DependencyChain,
pub blocked: Option<String>,
pub hierarchy: crate::commands::parent::HierarchyFacts,
pub health: Option<crate::health::PortableScopeHealth>,
pub health_diagnostics: Vec<crate::health::HealthDiagnostic>,
pub scroll_offset: u16,
pub viewport: std::cell::Cell<u16>,
pub max_scroll: std::cell::Cell<u16>,
pub rel_cursor: usize,
pub related: Vec<crate::related::RelatedRef>,
pub related_cursor: usize,
pub read_only: bool,
pub epics: Vec<crate::commands::view::EpicBlockEntry>,
pub capacity: Vec<IssueCapacityView>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IssueCapacityView {
pub scope: String,
pub issue_line: String,
pub fields: Vec<(String, String)>,
pub diagnostics: Vec<String>,
}
impl DetailState {
pub fn clamp_rel_cursor(&mut self) {
if self.relationships.is_empty() {
self.rel_cursor = 0;
} else if self.rel_cursor >= self.relationships.len() {
self.rel_cursor = self.relationships.len() - 1;
}
}
pub fn clamp_related_cursor(&mut self) {
if self.related.is_empty() {
self.related_cursor = 0;
} else if self.related_cursor >= self.related.len() {
self.related_cursor = self.related.len() - 1;
}
}
pub fn page(&self) -> u16 {
self.viewport.get().saturating_sub(1).max(1)
}
pub fn focus_body_line(&mut self, body_line: usize) {
self.scroll_offset = u16::try_from(body_line)
.unwrap_or(u16::MAX)
.min(self.max_scroll.get());
}
pub fn scroll_by(&mut self, rows: i32) {
let next = i32::from(self.scroll_offset) + rows;
self.scroll_offset = next.clamp(0, i32::from(self.max_scroll.get())) as u16;
}
}
#[cfg(test)]
fn no_chain() -> crate::relationships::DependencyChain {
crate::relationships::DependencyChain {
root: 0,
paths: vec![],
truncated: false,
}
}
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(crate) fn dash_number<T: std::fmt::Display>(value: Option<T>) -> String {
value.map_or_else(|| "-".to_string(), |value| value.to_string())
}
pub(crate) fn dash_cell(value: Option<&str>) -> String {
let value = value.unwrap_or_default().trim();
if value.is_empty() {
return "-".to_string();
}
value
.chars()
.map(|ch| if ch == '\n' || ch == '\r' { ' ' } else { ch })
.collect()
}
const TIME_COLUMNS: [&str; 5] = ["ENTRY", "MINUTES", "DATE", "AUTHOR", "NOTE"];
const TIME_WIDTHS: [usize; 5] = [18, 7, 10, 14, 0];
pub(crate) fn time_entry_heading() -> String {
format!(
"{:<18} {:>7} {:<10} {:<14} {}",
TIME_COLUMNS[0], TIME_COLUMNS[1], TIME_COLUMNS[2], TIME_COLUMNS[3], TIME_COLUMNS[4]
)
}
pub(crate) fn time_entry_row(entry: &crate::time_tracking::TimeEntry) -> String {
let cells = [
dash_cell(Some(entry.id.as_str())),
dash_number(entry.minutes()),
dash_cell(entry.date.as_deref()),
dash_cell(Some(entry.author.as_str())),
dash_cell(entry.note.as_deref()),
];
let mut row = String::new();
for (index, cell) in cells.iter().enumerate() {
if index > 0 {
row.push_str(" ");
}
let width = TIME_WIDTHS[index];
if index == 1 {
row.push_str(&format!("{cell:>width$}"));
} else if width == 0 {
row.push_str(cell);
} else {
row.push_str(&format!("{cell:<width$}"));
}
}
row
}
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(""));
for line_text in detail.issue.body.lines() {
lines.push(Line::from(Span::raw(line_text.to_string())));
}
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 para = Paragraph::new(lines)
.scroll((detail.scroll_offset, 0))
.block(Block::default().borders(Borders::ALL).title(title));
let viewport = area.height.saturating_sub(2); detail.viewport.set(viewport);
detail.max_scroll.set(
u16::try_from(content_lines)
.unwrap_or(u16::MAX)
.saturating_sub(viewport),
);
f.render_widget(para, area);
if let Some(idx) = files_line_index {
inject_files_hyperlinks(
f.buffer_mut(),
area,
idx,
detail.scroll_offset,
&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())
}
fn osc8_start(uri: &str) -> String {
format!("\u{1b}]8;;{uri}\u{1b}\\")
}
fn osc8_end() -> String {
"\u{1b}]8;;\u{1b}\\".to_string()
}
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())
}
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; }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::*;
fn sample_issue() -> Issue {
Issue {
projects: None,
id: None,
number: 1,
title: "Test Issue".into(),
status: Status::new("todo"),
labels: vec!["bug".into()],
priority: Priority::High,
assignee: Some("alice".into()),
estimate: Some("2".into()),
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![crate::relationships::Relationship::new(
1,
12,
crate::relationships::RelationKind::Related,
"Twelve",
)],
related_issues: vec![],
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
parent: None,
series: None,
health: None,
files: vec!["openspec/changes/x/proposal.md".into()],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".into(),
type_: None,
updated: "2026-01-01".into(),
filename: "test.md".into(),
body: "Line one\n\nLine two".into(),
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
entered_at: None,
source: None,
}
}
fn render_text(detail: &DetailState) -> String {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let backend = TestBackend::new(120, 40);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|f| render(detail, f, f.area(), &crate::ui::color::Theme::default()))
.unwrap();
let buf = terminal.backend().buffer();
buf.content()
.iter()
.map(|c| c.symbol())
.collect::<Vec<_>>()
.chunks(120)
.map(|row| {
row.iter()
.copied()
.collect::<String>()
.trim_end()
.to_string()
})
.collect::<Vec<_>>()
.join("\n")
}
fn detail_with(
relationships: Vec<crate::relationships::Relationship>,
chain: crate::relationships::DependencyChain,
blocked: Option<String>,
) -> DetailState {
let issue = sample_issue();
DetailState {
health: None,
health_diagnostics: Vec::new(),
wip: None,
category: None,
hierarchy: crate::commands::parent::facts_over(
std::slice::from_ref(&issue),
"done",
issue.number,
),
issue,
relationships,
chain,
blocked,
scroll_offset: 0,
viewport: std::cell::Cell::new(0),
max_scroll: std::cell::Cell::new(0),
rel_cursor: 0,
related: Vec::new(),
related_cursor: 0,
read_only: false,
epics: Vec::new(),
capacity: Vec::new(),
}
}
#[test]
fn the_relationship_block_covers_a_blocks_edge_a_related_only_issue_and_the_empty_state() {
let blocks = detail_with(
vec![crate::relationships::Relationship::new(
1,
2,
crate::relationships::RelationKind::Blocks,
"Refactor store",
)],
no_chain(),
Some("Blocked by #1 Ship the thing".to_string()),
);
let text = render_text(&blocks);
assert!(text.contains("blocks #2 Refactor store"), "got:\n{text}");
assert!(text.contains("blocked: Blocked by #1"), "got:\n{text}");
assert!(text.contains("chain: none"), "got:\n{text}");
let related = detail_with(
vec![crate::relationships::Relationship::new(
1,
3,
crate::relationships::RelationKind::Related,
"Notes",
)],
no_chain(),
None,
);
let text = render_text(&related);
assert!(text.contains("related #3 Notes"), "got:\n{text}");
assert!(
text.contains("blocked: not blocked"),
"a `related` edge must not read as blocking: got:\n{text}"
);
assert!(
!text.contains("blocked: related"),
"and nothing may present a non-blocks edge as a blocker: got:\n{text}"
);
let empty = detail_with(vec![], no_chain(), None);
let text = render_text(&empty);
assert!(text.contains("blocked: not blocked"), "got:\n{text}");
assert!(text.contains("chain: none"), "got:\n{text}");
assert!(
!text.contains("links: "),
"an issue with no relationships has no links row: got:\n{text}"
);
}
#[test]
fn the_relationship_block_is_ordered_like_the_cli_detail() {
let mut chain = no_chain();
chain.paths.push(vec![crate::relationships::ChainNode {
number: 1,
title: "Ship it".into(),
}]);
let text = render_text(&detail_with(
vec![crate::relationships::Relationship::new(
1,
2,
crate::relationships::RelationKind::Blocks,
"Refactor store",
)],
chain,
Some("Blocked by #3 Something".to_string()),
));
let links = text.find("links: ").expect("a links row");
let chain_at = text.find("chain: ").expect("a chain row");
let blocked = text.find("blocked: ").expect("a blocked row");
assert!(
links < chain_at && chain_at < blocked,
"relationships, then chain, then blocked — got:\n{text}"
);
}
#[test]
fn render_files_line_emits_clickable_hyperlink() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let backend = TestBackend::new(120, 40);
let mut terminal = Terminal::new(backend).unwrap();
let detail = DetailState {
health: None,
health_diagnostics: Vec::new(),
wip: None,
category: None,
issue: sample_issue(),
relationships: sample_issue().relationships,
chain: no_chain(),
blocked: None,
hierarchy: crate::commands::parent::facts_over(
std::slice::from_ref(&sample_issue()),
"done",
sample_issue().number,
),
scroll_offset: 0,
viewport: std::cell::Cell::new(0),
max_scroll: std::cell::Cell::new(0),
rel_cursor: 0,
related: Vec::new(),
related_cursor: 0,
read_only: false,
epics: Vec::new(),
capacity: Vec::new(),
};
terminal
.draw(|f| render(&detail, f, f.area(), &crate::ui::color::Theme::default()))
.unwrap();
let buf = terminal.backend().buffer();
let text: String = buf
.content()
.iter()
.map(|c| c.symbol().to_string())
.collect::<Vec<_>>()
.join("");
let link = hyperlink(
&file_uri("openspec/changes/x/proposal.md"),
"openspec/changes/x/proposal.md",
);
assert!(
text.contains(&link),
"files must render as an OSC 8 hyperlink to the resolved file"
);
assert!(text.contains("related #12 Twelve"), "got:\n{text}");
}
#[test]
fn hyperlink_wraps_label_and_keeps_file_uris() {
assert_eq!(
hyperlink("file:///a/b.md", "b.md"),
"\u{1b}]8;;file:///a/b.md\u{1b}\\b.md\u{1b}]8;;\u{1b}\\"
);
assert_eq!(file_uri("file:///already/abs.md"), "file:///already/abs.md");
assert!(file_uri("openspec/x.md").starts_with("file:///"));
}
#[test]
fn render_smoke_small_frame() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let backend = TestBackend::new(10, 5);
let mut terminal = Terminal::new(backend).unwrap();
let detail = DetailState {
health: None,
health_diagnostics: Vec::new(),
wip: None,
category: None,
issue: sample_issue(),
relationships: sample_issue().relationships,
chain: no_chain(),
blocked: None,
hierarchy: crate::commands::parent::facts_over(
std::slice::from_ref(&sample_issue()),
"done",
sample_issue().number,
),
scroll_offset: 0,
viewport: std::cell::Cell::new(0),
max_scroll: std::cell::Cell::new(0),
rel_cursor: 0,
related: Vec::new(),
related_cursor: 0,
read_only: false,
epics: Vec::new(),
capacity: Vec::new(),
};
terminal
.draw(|f| render(&detail, f, f.area(), &crate::ui::color::Theme::default()))
.unwrap();
}
#[test]
fn render_smoke_large_frame() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let backend = TestBackend::new(80, 40);
let mut terminal = Terminal::new(backend).unwrap();
let detail = DetailState {
health: None,
health_diagnostics: Vec::new(),
wip: None,
category: None,
issue: sample_issue(),
relationships: sample_issue().relationships,
chain: no_chain(),
blocked: None,
hierarchy: crate::commands::parent::facts_over(
std::slice::from_ref(&sample_issue()),
"done",
sample_issue().number,
),
scroll_offset: 0,
viewport: std::cell::Cell::new(0),
max_scroll: std::cell::Cell::new(0),
rel_cursor: 0,
related: Vec::new(),
related_cursor: 0,
read_only: false,
epics: Vec::new(),
capacity: Vec::new(),
};
terminal
.draw(|f| render(&detail, f, f.area(), &crate::ui::color::Theme::default()))
.unwrap();
}
#[test]
fn render_scroll_offset() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let backend = TestBackend::new(80, 10);
let mut terminal = Terminal::new(backend).unwrap();
let issue = sample_issue();
let relationships = issue.relationships.clone();
let detail = DetailState {
health: None,
health_diagnostics: Vec::new(),
wip: None,
category: None,
hierarchy: crate::commands::parent::facts_over(
std::slice::from_ref(&issue),
"done",
issue.number,
),
issue,
relationships,
chain: no_chain(),
blocked: None,
scroll_offset: 5,
viewport: std::cell::Cell::new(0),
max_scroll: std::cell::Cell::new(0),
rel_cursor: 0,
related: Vec::new(),
related_cursor: 0,
read_only: false,
epics: Vec::new(),
capacity: Vec::new(),
};
terminal
.draw(|f| render(&detail, f, f.area(), &crate::ui::color::Theme::default()))
.unwrap();
}
#[test]
fn the_related_block_renders_a_count_chips_and_the_selection_marker() {
let mut detail = detail_with(
vec![crate::relationships::Relationship::new(
1,
2,
crate::relationships::RelationKind::Related,
"Twelve",
)],
no_chain(),
None,
);
detail.related = crate::related::classify_all(&[
"12".to_string(),
"../other-repo/issues/2026-01-01-x.md".to_string(),
"https://gitlab.com/foo/bar#7".to_string(),
]);
detail.related_cursor = 1;
let text = render_text(&detail);
assert!(
text.contains("related: 3"),
"a count is drawn: got:\n{text}"
);
let cell = |line: &str| {
line.trim_matches(|c: char| c == '│' || c == ' ')
.to_string()
};
assert!(
text.lines().any(|line| cell(line) == "12"),
"the local chip is drawn: got:\n{text}"
);
assert!(
text.lines()
.any(|line| cell(line).contains("../other-repo/issues/2026-01-01-x.md")),
"the path chip is drawn: got:\n{text}"
);
assert!(
text.lines()
.any(|line| cell(line).contains("https://gitlab.com/foo/bar#7")),
"the URL chip is drawn: got:\n{text}"
);
let selected = text
.lines()
.find(|line| cell(line).contains("../other-repo"))
.expect("the selected line");
assert!(
cell(selected).starts_with("> "),
"the selected related entry needs a marker: {selected:?}"
);
let first = text
.lines()
.find(|line| cell(line) == "12")
.expect("the first line");
assert!(
!cell(first).contains('>'),
"unselected related entries carry no marker: {first:?}"
);
let related_at = text.find("related: 3").expect("the related block");
let links_at = text.find("links: ").expect("the relationship row");
assert!(
related_at < links_at,
"the related block must precede the links row: got:\n{text}"
);
let empty = detail_with(vec![], no_chain(), None);
let empty_text = render_text(&empty);
assert!(
!empty_text.contains("related:"),
"an issue with no related_issues must draw no block: got:\n{empty_text}"
);
}
#[test]
fn assignee_render_identity_verbatim_bare_gets_at() {
assert_eq!(render_assignee(None), None);
assert_eq!(
render_assignee(Some("Herbert Maschke <thyseus@pm.me>")),
Some("Herbert Maschke <thyseus@pm.me>".to_string())
);
assert_eq!(render_assignee(Some("alice")), Some("@alice".to_string()));
assert_eq!(render_assignee(Some("@alice")), Some("@alice".to_string()));
}
fn epic_entry(heading: &str, detail: &[&str]) -> crate::commands::view::EpicBlockEntry {
crate::commands::view::EpicBlockEntry {
heading: heading.to_string(),
detail: detail.iter().map(|line| (*line).to_string()).collect(),
}
}
#[test]
fn t019_the_pane_renders_the_canonical_epic_rows_and_the_navigation_affordance() {
let mut detail = detail_with(vec![], no_chain(), None);
detail.epics = vec![epic_entry(
"Alpha (e-aaa111)",
&[
"progress: 1/2 done",
"weighted progress: 2/2 (100%)",
"children: 1",
"blocked: 0",
],
)];
let text = render_text(&detail);
assert!(text.contains("epics:"), "{text}");
assert!(text.contains("Alpha (e-aaa111)"), "{text}");
assert!(
text.contains("progress: 1/2 done"),
"the pane renders the same facts as `tix show`: {text}"
);
assert!(text.contains("weighted progress: 2/2 (100%)"), "{text}");
assert!(
text.contains("members open directly from the epic view"),
"the direct issue navigation affordance is explicit (FR-004): {text}"
);
}
#[test]
fn t019_an_issue_in_no_epic_draws_no_epic_heading() {
let detail = detail_with(vec![], no_chain(), None);
let text = render_text(&detail);
assert!(
!text.contains("epics:"),
"an empty reference list is the empty state, not an empty heading: {text}"
);
}
#[test]
fn t019_a_missing_epic_reference_is_rendered_with_its_id_and_reason() {
let mut detail = detail_with(vec![], no_chain(), None);
detail.epics = vec![epic_entry(
"e-gone1 (missing reference)",
&[
"issue #5 records epic reference `e-gone1`, which is not in the registry \
(the reference is kept so it can be repaired)",
],
)];
let text = render_text(&detail);
assert!(
text.contains("e-gone1 (missing reference)"),
"a missing reference is named, not dropped (C-005): {text}"
);
assert!(text.contains("not in the registry"), "{text}");
}
}