use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph, Wrap},
};
use crate::prelude::*;
use input::{Key, KeyEvent};
use operation::{Event, IssueViewOp, Operation};
use settings::Settings;
use views::View;
use widgets::focus::FocusRing;
use widgets::focusable::FocusOp;
use crate::issue_registry::{IssueId, Severity};
const FOCUS_FILTER: &str = "filter";
const FOCUS_LIST: &str = "list";
#[allow(dead_code)] const FOCUS_DETAIL: &str = "detail";
#[derive(Debug, Clone)]
enum ViewMode {
List,
#[allow(dead_code)] Detail { id: IssueId, scroll: usize },
}
#[derive(Debug)]
pub struct IssueView {
filter: widgets::input_field::InputField,
focus: FocusRing,
cursor: usize,
mode: ViewMode,
severity_filter: Option<Severity>,
show_dismissed: bool,
}
impl Default for IssueView {
fn default() -> Self {
Self::new()
}
}
impl IssueView {
pub fn new() -> Self {
Self {
filter: widgets::input_field::InputField::new("Filter"),
focus: FocusRing::new(vec![FOCUS_FILTER, FOCUS_LIST]),
cursor: 0,
mode: ViewMode::List,
severity_filter: None,
show_dismissed: false,
}
}
fn move_up(&mut self, n: usize) {
self.cursor = self.cursor.saturating_sub(n);
}
fn move_down(&mut self, n: usize, list_len: usize) {
if list_len == 0 { return; }
self.cursor = (self.cursor + n).min(list_len - 1);
}
pub fn clamp_cursor(&mut self, list_len: usize) {
if list_len == 0 { self.cursor = 0; }
else { self.cursor = self.cursor.min(list_len - 1); }
}
fn back_to_list(&mut self) {
self.mode = ViewMode::List;
self.focus = FocusRing::new(vec![FOCUS_FILTER, FOCUS_LIST]);
self.focus.set_focus(FOCUS_LIST);
}
pub fn is_detail(&self) -> bool {
matches!(self.mode, ViewMode::Detail { .. })
}
#[allow(dead_code)] fn open_detail(&mut self, id: IssueId) {
self.mode = ViewMode::Detail { id, scroll: 0 };
self.focus = FocusRing::new(vec![FOCUS_DETAIL]);
self.focus.set_focus(FOCUS_DETAIL);
}
pub fn selected_issue_id(
&self,
issue_registry: &crate::issue_registry::IssueRegistry,
) -> Option<IssueId> {
let entries = self.filtered_sorted(issue_registry);
if entries.is_empty() { return None; }
let idx = self.cursor.min(entries.len() - 1);
entries.get(idx).map(|i| i.id)
}
fn filtered_sorted<'r>(
&self,
reg: &'r crate::issue_registry::IssueRegistry,
) -> Vec<&'r crate::issue_registry::Issue> {
let q = self.filter.text().to_lowercase();
let mut entries: Vec<&crate::issue_registry::Issue> = reg
.list_all()
.into_iter()
.filter(|i| {
if !self.show_dismissed && (i.dismissed || i.resolved) {
return false;
}
if self.severity_filter.is_some_and(|sev| i.severity != sev) {
return false;
}
if q.is_empty() { return true; }
i.message.to_lowercase().contains(&q)
|| i.source.to_lowercase().contains(&q)
|| i.path.as_ref().is_some_and(|p| {
p.to_string_lossy().to_lowercase().contains(&q)
})
})
.collect();
entries.sort_by(|a, b| {
b.severity.cmp(&a.severity)
.then(b.created_at.cmp(&a.created_at))
});
entries
}
pub fn ops_resolve_selected(
&self,
issue_registry: &crate::issue_registry::IssueRegistry,
) -> Vec<Operation> {
let Some(id) = self.selected_issue_id(issue_registry) else { return vec![]; };
let Some(issue) = issue_registry.get(id) else { return vec![]; };
if issue.marker.is_none() {
vec![Operation::PersistentIssueLocal(
operation::PersistentIssueOp::Resolve { id },
)]
} else {
vec![Operation::ResolveIssue { id }]
}
}
pub fn ops_dismiss_selected(
&self,
issue_registry: &crate::issue_registry::IssueRegistry,
) -> Vec<Operation> {
let Some(id) = self.selected_issue_id(issue_registry) else { return vec![]; };
let Some(issue) = issue_registry.get(id) else { return vec![]; };
if issue.marker.is_none() {
vec![Operation::PersistentIssueLocal(
operation::PersistentIssueOp::Dismiss { id },
)]
} else {
vec![Operation::DismissIssue { id }]
}
}
pub fn ops_goto_selected(
&self,
issue_registry: &crate::issue_registry::IssueRegistry,
) -> Vec<Operation> {
let id = match &self.mode {
ViewMode::Detail { id, .. } => *id,
ViewMode::List => match self.selected_issue_id(issue_registry) {
Some(id) => id,
None => return vec![],
},
};
let Some(issue) = issue_registry.get(id) else { return vec![]; };
let Some(ref path) = issue.path else { return vec![]; };
let mut ops = vec![
Operation::Close,
Operation::OpenFile { path: path.clone() },
];
if let Some((start, _)) = issue.range {
ops.push(Operation::MoveCursor {
path: Some(path.clone()),
cursor: start,
});
}
ops
}
}
impl View for IssueView {
const KIND: views::ViewKind = views::ViewKind::Modal;
fn save_state(&mut self, _app: &mut crate::app_state::AppState) {}
fn handle_key(&self, key: KeyEvent) -> Vec<Operation> {
if let ViewMode::Detail { .. } = &self.mode {
return match key.key {
Key::Char('r') => vec![Operation::IssueViewLocal(IssueViewOp::ResolveSelected)],
Key::Char('d') => vec![Operation::IssueViewLocal(IssueViewOp::DismissSelected)],
Key::Enter | Key::F(12) => vec![Operation::IssueViewLocal(IssueViewOp::OpenSelected)],
Key::ArrowUp | Key::Char('k') => vec![Operation::NavigateUp],
Key::ArrowDown | Key::Char('j') => vec![Operation::NavigateDown],
Key::PageUp => vec![Operation::NavigatePageUp],
Key::PageDown => vec![Operation::NavigatePageDown],
Key::Home => vec![Operation::NavigateHome],
Key::End => vec![Operation::NavigateEnd],
_ => vec![],
};
}
if key.key == Key::Tab { return vec![Operation::Focus(FocusOp::Next)]; }
if key.key == Key::BackTab { return vec![Operation::Focus(FocusOp::Prev)]; }
match key.key {
Key::Char('0') => return vec![Operation::IssueViewLocal(IssueViewOp::SetSeverityFilter(None))],
Key::Char('1') => return vec![Operation::IssueViewLocal(IssueViewOp::SetSeverityFilter(Some(Severity::Error)))],
Key::Char('2') => return vec![Operation::IssueViewLocal(IssueViewOp::SetSeverityFilter(Some(Severity::Warning)))],
Key::Char('3') => return vec![Operation::IssueViewLocal(IssueViewOp::SetSeverityFilter(Some(Severity::Info)))],
Key::Char('4') => return vec![Operation::IssueViewLocal(IssueViewOp::SetSeverityFilter(Some(Severity::Todo)))],
Key::Char('h') => return vec![Operation::IssueViewLocal(IssueViewOp::ToggleShowDismissed)],
_ => {}
}
match self.focus.current() {
FOCUS_FILTER => {
if let Some(field_op) = widgets::input_field::key_to_op(key) {
return vec![Operation::IssueViewLocal(IssueViewOp::FilterInput(field_op))];
}
match key.key {
Key::ArrowUp => vec![Operation::NavigateUp],
Key::ArrowDown => vec![Operation::NavigateDown],
Key::PageUp => vec![Operation::NavigatePageUp],
Key::PageDown => vec![Operation::NavigatePageDown],
_ => vec![],
}
}
FOCUS_LIST => {
match key.key {
Key::ArrowUp => vec![Operation::NavigateUp],
Key::ArrowDown => vec![Operation::NavigateDown],
Key::PageUp => vec![Operation::NavigatePageUp],
Key::PageDown => vec![Operation::NavigatePageDown],
Key::Home => vec![Operation::NavigateHome],
Key::End => vec![Operation::NavigateEnd],
Key::Enter | Key::F(12) => vec![Operation::IssueViewLocal(IssueViewOp::OpenSelected)],
Key::Char('r') | Key::Char('R') => {
vec![Operation::IssueViewLocal(IssueViewOp::ResolveSelected)]
}
Key::Char('d') | Key::Char('D') => {
vec![Operation::IssueViewLocal(IssueViewOp::DismissSelected)]
}
_ => {
if let Some(field_op) = widgets::input_field::key_to_op(key) {
return vec![Operation::IssueViewLocal(IssueViewOp::FilterInput(field_op))];
}
vec![]
}
}
}
_ => vec![],
}
}
fn handle_mouse(&self, _mouse: input::MouseEvent) -> Vec<Operation> {
vec![]
}
fn handle_operation(&mut self, op: &Operation, _settings: &Settings) -> Option<Event> {
match op {
Operation::Focus(focus_op) => {
match focus_op {
FocusOp::Next => self.focus.focus_next(),
FocusOp::Prev => self.focus.focus_prev(),
_ => {}
}
Some(Event::applied("issue_view", op.clone()))
}
Operation::IssueViewLocal(local_op) => {
match local_op {
IssueViewOp::FilterInput(field_op) => {
self.filter.apply(field_op);
self.cursor = 0;
}
IssueViewOp::OpenSelected => {
}
IssueViewOp::CloseDetail => {
self.back_to_list();
}
IssueViewOp::ResolveSelected | IssueViewOp::DismissSelected => {
}
IssueViewOp::ToggleShowDismissed => {
self.show_dismissed = !self.show_dismissed;
self.cursor = 0;
}
IssueViewOp::SetSeverityFilter(sev) => {
self.severity_filter = *sev;
self.cursor = 0;
}
IssueViewOp::RegistryChanged => {
}
}
Some(Event::applied("issue_view", op.clone()))
}
Operation::IssueAdded { .. }
| Operation::IssueRemoved { .. }
| Operation::IssueUpdated { .. } => {
Some(Event::applied("issue_view", op.clone()))
}
Operation::NavigateUp => {
match &mut self.mode {
ViewMode::Detail { scroll, .. } => {
*scroll = scroll.saturating_sub(1);
}
ViewMode::List => self.move_up(1),
}
Some(Event::applied("issue_view", op.clone()))
}
Operation::NavigateDown => {
match &mut self.mode {
ViewMode::Detail { scroll, .. } => {
*scroll = scroll.saturating_add(1);
}
ViewMode::List => self.move_down(1, usize::MAX),
}
Some(Event::applied("issue_view", op.clone()))
}
Operation::NavigatePageUp => {
match &mut self.mode {
ViewMode::Detail { scroll, .. } => {
*scroll = scroll.saturating_sub(20);
}
ViewMode::List => self.move_up(10),
}
Some(Event::applied("issue_view", op.clone()))
}
Operation::NavigatePageDown => {
match &mut self.mode {
ViewMode::Detail { scroll, .. } => {
*scroll = scroll.saturating_add(20);
}
ViewMode::List => self.move_down(10, usize::MAX),
}
Some(Event::applied("issue_view", op.clone()))
}
Operation::NavigateHome => {
match &mut self.mode {
ViewMode::Detail { scroll, .. } => { *scroll = 0; }
ViewMode::List => { self.cursor = 0; }
}
Some(Event::applied("issue_view", op.clone()))
}
Operation::NavigateEnd => {
Some(Event::applied("issue_view", op.clone()))
}
_ => None,
}
}
fn render(&self, frame: &mut Frame, area: Rect, theme: &crate::theme::Theme) {
let _ = (frame, area, theme);
}
fn status_bar(
&self,
state: &crate::app_state::AppState,
bar: &mut crate::widgets::status_bar::StatusBarBuilder,
) {
let reg = &state.issue_registry;
let errors = reg.list_by_severity(Severity::Error).len();
let warnings = reg.list_by_severity(Severity::Warning).len();
let info = reg.list_by_severity(Severity::Info).len();
match &self.mode {
ViewMode::List => {
if errors + warnings + info > 0 {
bar.label(format!("{}E {}W {}I ↑↓ navigate · r resolve · d dismiss · h toggle dismissed · Esc close", errors, warnings, info));
} else {
bar.label("Issues ↑↓ navigate · r resolve · d dismiss · Esc close");
}
}
ViewMode::Detail { .. } => {
bar.label("Issue detail ↑↓ scroll · Enter jump to file · r resolve · Esc back");
}
}
}
}
impl IssueView {
pub fn render_with_registry(
&self,
frame: &mut Frame,
area: Rect,
theme: &crate::theme::Theme,
reg: &crate::issue_registry::IssueRegistry,
) {
match &self.mode {
ViewMode::List => self.render_list(frame, area, theme, reg),
ViewMode::Detail { id, scroll } => self.render_detail(frame, area, theme, reg, *id, *scroll),
}
}
fn render_list(
&self,
frame: &mut Frame,
area: Rect,
theme: &crate::theme::Theme,
reg: &crate::issue_registry::IssueRegistry,
) {
let bg = theme.bg();
let fg = theme.fg();
let dim = theme.fg_dim();
let accent = theme.accent();
let sel_bg = theme.selection_bg();
let outer_block = Block::default()
.borders(Borders::ALL)
.title(Span::styled(" Issues ", Style::default().fg(accent).add_modifier(Modifier::BOLD)))
.style(Style::default().fg(fg).bg(bg));
let inner = outer_block.inner(area);
frame.render_widget(outer_block, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(inner);
let filter_area = chunks[0];
let list_area = chunks[1];
let filter_focused = self.focus.current() == FOCUS_FILTER;
let filter_style = if filter_focused {
Style::default().fg(accent).bg(bg)
} else {
Style::default().fg(dim).bg(bg)
};
let sev_options: &[(&str, Option<Severity>)] = &[
("All", None),
("E", Some(Severity::Error)),
("W", Some(Severity::Warning)),
("I", Some(Severity::Info)),
("T", Some(Severity::Todo)),
];
let mut filter_spans: Vec<Span> = vec![
Span::styled(
format!("{} {}", if filter_focused { ">" } else { " " }, self.filter.text()),
filter_style,
),
Span::raw(" "),
];
for (lbl, sev) in sev_options {
let active = &self.severity_filter == sev;
let style = if active {
Style::default().fg(bg).bg(accent)
} else {
Style::default().fg(dim).bg(bg)
};
filter_spans.push(Span::styled(format!("[{}]", lbl), style));
filter_spans.push(Span::raw(" "));
}
let hidden_style = if self.show_dismissed {
Style::default().fg(accent).bg(bg)
} else {
Style::default().fg(dim).bg(bg)
};
filter_spans.push(Span::styled("[h:dismissed]", hidden_style));
frame.render_widget(
Paragraph::new(Line::from(filter_spans)).style(Style::default().bg(bg)),
filter_area,
);
let entries = self.filtered_sorted(reg);
let list_len = entries.len();
let cursor = if list_len == 0 { 0 } else { self.cursor.min(list_len - 1) };
let visible_rows = list_area.height as usize;
let scroll_offset = if cursor >= visible_rows { cursor + 1 - visible_rows } else { 0 };
let list_focused = self.focus.current() == FOCUS_LIST;
let items: Vec<ListItem> = entries
.iter()
.enumerate()
.skip(scroll_offset)
.take(visible_rows)
.map(|(idx, issue)| {
let is_sel = list_focused && idx == cursor;
let row_bg = if is_sel { sel_bg } else { bg };
let row_fg = if is_sel { accent } else { fg };
let (ind, ind_style) = severity_indicator(issue.severity, theme);
let source_pad = format!("{:<8}", &issue.source[..issue.source.len().min(8)]);
let loc = issue.path.as_ref().map(|p| {
let file = p.file_name().and_then(|n| n.to_str()).unwrap_or("?");
if let Some((start, _)) = issue.range {
format!("{}:{}:{}", file, start.line + 1, start.column)
} else {
file.to_string()
}
}).unwrap_or_else(|| "(no file)".to_string());
let loc_pad = format!("{:<24}", truncate_str(&loc, 24));
let msg = truncate_str(&issue.message, 60);
let msg_style = if issue.dismissed || issue.resolved {
Style::default().fg(dim).bg(row_bg).add_modifier(Modifier::DIM)
} else {
Style::default().fg(row_fg).bg(row_bg)
};
let line = Line::from(vec![
Span::styled(ind, ind_style.bg(row_bg)),
Span::styled(" ", Style::default().bg(row_bg)),
Span::styled(source_pad, Style::default().fg(dim).bg(row_bg)),
Span::styled(" ", Style::default().bg(row_bg)),
Span::styled(loc_pad, Style::default().fg(dim).bg(row_bg)),
Span::styled(" ", Style::default().bg(row_bg)),
Span::styled(msg, msg_style),
]);
ListItem::new(line)
})
.collect();
frame.render_widget(
List::new(items).style(Style::default().fg(fg).bg(bg)),
list_area,
);
if list_len == 0 {
let msg = if self.filter.text().is_empty() && self.severity_filter.is_none() {
"No issues."
} else {
"No matching issues."
};
frame.render_widget(
Paragraph::new(msg).style(Style::default().fg(dim).bg(bg)),
list_area,
);
}
}
fn render_detail(
&self,
frame: &mut Frame,
area: Rect,
theme: &crate::theme::Theme,
reg: &crate::issue_registry::IssueRegistry,
id: IssueId,
scroll: usize,
) {
let bg = theme.bg();
let fg = theme.fg();
let dim = theme.fg_dim();
let Some(issue) = reg.get(id) else {
frame.render_widget(
Paragraph::new("Issue no longer exists. Press Esc to go back.")
.style(Style::default().fg(dim).bg(bg)),
area,
);
return;
};
let (sev_str, sev_style) = match issue.severity {
Severity::Error => ("✗ Error", Style::default().fg(theme.level_error())),
Severity::Warning => ("⚠ Warning", Style::default().fg(theme.level_warn())),
Severity::Info => ("ℹ Info", Style::default().fg(fg)),
Severity::Todo => ("☑ Todo", Style::default().fg(dim)),
};
let block_title = format!(" ← Issues — {} ", sev_str);
let visible_rows = area.height.saturating_sub(2) as usize;
let loc_line = issue.path.as_ref().map(|p| {
let path_str = p.to_string_lossy();
if let Some((start, _)) = issue.range {
format!("{}:{}:{}", path_str, start.line + 1, start.column)
} else {
path_str.into_owned()
}
}).unwrap_or_else(|| "(no file)".to_string());
let status_str = if issue.dismissed { "dismissed" }
else if issue.resolved { "resolved" }
else { "active" };
let mut detail_lines: Vec<Line> = vec![
Line::from(vec![
Span::styled("File: ", Style::default().fg(dim).bg(bg)),
Span::styled(loc_line, Style::default().fg(fg).bg(bg)),
]),
Line::from(vec![
Span::styled("Source: ", Style::default().fg(dim).bg(bg)),
Span::styled(issue.source.clone(), Style::default().fg(fg).bg(bg)),
]),
Line::from(vec![
Span::styled("Severity: ", Style::default().fg(dim).bg(bg)),
Span::styled(sev_str, sev_style.bg(bg)),
]),
Line::from(vec![
Span::styled("Status: ", Style::default().fg(dim).bg(bg)),
Span::styled(status_str, Style::default().fg(fg).bg(bg)),
]),
Line::from(""),
];
let wrap_width = area.width.saturating_sub(4) as usize;
for para in issue.message.split('\n') {
for wrapped_line in word_wrap(para, wrap_width.max(1)) {
detail_lines.push(Line::from(Span::raw(wrapped_line)));
}
}
let max_scroll = detail_lines.len().saturating_sub(visible_rows);
let scroll = scroll.min(max_scroll);
let rendered: Vec<Line> = detail_lines
.iter()
.skip(scroll)
.take(visible_rows)
.cloned()
.collect();
let block = Block::default()
.borders(Borders::ALL)
.title(Span::styled(block_title, sev_style.add_modifier(Modifier::BOLD)))
.style(Style::default().fg(fg).bg(bg));
frame.render_widget(
Paragraph::new(rendered)
.block(block)
.wrap(Wrap { trim: false })
.style(Style::default().fg(fg).bg(bg)),
area,
);
if detail_lines.len() > visible_rows {
let hint = format!(
" lines {}-{}/{} ",
scroll + 1,
(scroll + visible_rows).min(detail_lines.len()),
detail_lines.len()
);
let hw = hint.len() as u16;
if area.width > hw + 2 {
let hint_rect = Rect {
x: area.x + area.width - hw - 1,
y: area.y + area.height - 1,
width: hw,
height: 1,
};
frame.render_widget(
Paragraph::new(Span::styled(hint, Style::default().fg(dim).bg(bg))),
hint_rect,
);
}
}
}
}
fn severity_indicator(sev: Severity, theme: &crate::theme::Theme) -> (&'static str, Style) {
match sev {
Severity::Error => ("✗", Style::default().fg(theme.level_error())),
Severity::Warning => ("⚠", Style::default().fg(theme.level_warn())),
Severity::Info => ("ℹ", Style::default().fg(theme.fg())),
Severity::Todo => ("☑", Style::default().fg(theme.fg_dim())),
}
}
fn truncate_str(s: &str, max_chars: usize) -> String {
let chars: Vec<char> = s.chars().collect();
if chars.len() <= max_chars {
s.to_string()
} else {
let truncated: String = chars[..max_chars.saturating_sub(1)].iter().collect();
format!("{}…", truncated)
}
}
fn word_wrap(text: &str, max_width: usize) -> Vec<String> {
if text.trim().is_empty() { return vec![String::new()]; }
let mut lines = Vec::new();
let mut current = String::new();
for word in text.split_whitespace() {
if current.is_empty() {
current.push_str(word);
} else if current.len() + 1 + word.len() <= max_width {
current.push(' ');
current.push_str(word);
} else {
lines.push(current);
current = word.to_string();
}
}
if !current.is_empty() {
lines.push(current);
}
if lines.is_empty() { lines.push(String::new()); }
lines
}
#[cfg(test)]
mod tests {
use super::*;
use crate::issue_registry::{IssueRegistry, NewIssue, Severity};
use input::Modifiers;
fn make_issue(msg: &str, sev: Severity) -> NewIssue {
NewIssue {
marker: None,
source: "test".into(),
path: None,
range: None,
message: msg.into(),
severity: sev,
}
}
fn dummy_settings() -> Settings {
let dir = tempfile::tempdir().expect("tempdir");
Settings::new(dir.path().join("config.yaml").as_path())
.expect("Settings::new in test")
}
fn dummy_key(key: Key) -> KeyEvent {
KeyEvent { key, modifiers: Modifiers::empty() }
}
#[test]
fn navigate_down_up() {
let mut view = IssueView::new();
view.focus.set_focus(FOCUS_LIST);
let mut reg = IssueRegistry::new();
reg.add_issue(make_issue("a", Severity::Error));
reg.add_issue(make_issue("b", Severity::Warning));
reg.add_issue(make_issue("c", Severity::Info));
let ops = view.handle_key(dummy_key(Key::ArrowDown));
assert!(ops.iter().any(|o| matches!(o, Operation::NavigateDown)));
view.handle_operation(&Operation::NavigateDown, &dummy_settings());
assert_eq!(view.cursor, 1);
view.handle_operation(&Operation::NavigateUp, &dummy_settings());
assert_eq!(view.cursor, 0);
}
#[test]
fn clamp_cursor_on_empty_registry() {
let mut view = IssueView::new();
view.cursor = 5;
let reg = IssueRegistry::new();
view.clamp_cursor(view.filtered_sorted(®).len());
assert_eq!(view.cursor, 0);
}
#[test]
fn severity_filter_ops() {
let mut view = IssueView::new();
let ops = view.handle_key(dummy_key(Key::Char('1')));
assert!(ops.iter().any(|o| matches!(
o,
Operation::IssueViewLocal(IssueViewOp::SetSeverityFilter(Some(Severity::Error)))
)));
view.handle_operation(
&Operation::IssueViewLocal(IssueViewOp::SetSeverityFilter(Some(Severity::Error))),
&dummy_settings(),
);
assert_eq!(view.severity_filter, Some(Severity::Error));
let ops = view.handle_key(dummy_key(Key::Char('0')));
assert!(ops.iter().any(|o| matches!(
o,
Operation::IssueViewLocal(IssueViewOp::SetSeverityFilter(None))
)));
}
#[test]
fn toggle_show_dismissed() {
let mut view = IssueView::new();
assert!(!view.show_dismissed);
let ops = view.handle_key(dummy_key(Key::Char('h')));
assert!(ops.iter().any(|o| matches!(
o,
Operation::IssueViewLocal(IssueViewOp::ToggleShowDismissed)
)));
view.handle_operation(
&Operation::IssueViewLocal(IssueViewOp::ToggleShowDismissed),
&dummy_settings(),
);
assert!(view.show_dismissed);
}
#[test]
fn resolve_selected_emits_op() {
let mut view = IssueView::new();
view.focus.set_focus(FOCUS_LIST);
let ops = view.handle_key(dummy_key(Key::Char('r')));
assert!(ops.iter().any(|o| matches!(
o,
Operation::IssueViewLocal(IssueViewOp::ResolveSelected)
)));
}
#[test]
fn dismiss_selected_emits_op() {
let mut view = IssueView::new();
view.focus.set_focus(FOCUS_LIST);
let ops = view.handle_key(dummy_key(Key::Char('d')));
assert!(ops.iter().any(|o| matches!(
o,
Operation::IssueViewLocal(IssueViewOp::DismissSelected)
)));
}
#[test]
fn esc_in_list_yields_no_ops() {
let view = IssueView::new();
let ops = view.handle_key(dummy_key(Key::Escape));
assert!(ops.is_empty());
}
#[test]
fn esc_in_detail_yields_no_ops() {
let mut view = IssueView::new();
let mut reg = IssueRegistry::new();
let id = reg.add_issue(make_issue("x", Severity::Error));
view.mode = ViewMode::Detail { id, scroll: 0 };
view.focus.set_focus(FOCUS_DETAIL);
let ops = view.handle_key(dummy_key(Key::Escape));
assert!(ops.is_empty());
}
#[test]
fn selected_issue_id_returns_error_first() {
let mut view = IssueView::new();
let mut reg = IssueRegistry::new();
let id1 = reg.add_issue(make_issue("error msg", Severity::Error));
reg.add_issue(make_issue("warn msg", Severity::Warning));
view.cursor = 0;
assert_eq!(view.selected_issue_id(®), Some(id1));
}
#[test]
fn ops_goto_selected_no_path_returns_empty() {
let mut view = IssueView::new();
let mut reg = IssueRegistry::new();
reg.add_issue(make_issue("msg", Severity::Error));
view.cursor = 0;
let ops = view.ops_goto_selected(®);
assert!(ops.is_empty());
}
#[test]
fn filtered_sorted_hides_dismissed_by_default() {
let view = IssueView::new();
let mut reg = IssueRegistry::new();
let id = reg.add_issue(make_issue("dismissed issue", Severity::Error));
reg.dismiss_issue(id);
reg.add_issue(make_issue("active", Severity::Warning));
let entries = view.filtered_sorted(®);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].message, "active");
}
#[test]
fn open_detail_switches_mode() {
let mut view = IssueView::new();
let mut reg = IssueRegistry::new();
let id = reg.add_issue(make_issue("err", Severity::Error));
view.open_detail(id);
assert!(matches!(view.mode, ViewMode::Detail { id: detail_id, .. } if detail_id == id));
}
#[test]
fn close_detail_returns_to_list() {
let mut view = IssueView::new();
let mut reg = IssueRegistry::new();
let id = reg.add_issue(make_issue("err", Severity::Error));
view.open_detail(id);
view.handle_operation(
&Operation::IssueViewLocal(IssueViewOp::CloseDetail),
&dummy_settings(),
);
assert!(matches!(view.mode, ViewMode::List));
}
#[test]
fn f12_in_list_triggers_open_selected() {
let mut view = IssueView::new();
view.focus.set_focus(FOCUS_LIST);
let ops = view.handle_key(dummy_key(Key::F(12)));
assert!(ops.iter().any(|o| matches!(o, Operation::IssueViewLocal(IssueViewOp::OpenSelected))));
}
#[test]
fn f12_in_detail_triggers_open_selected() {
let mut view = IssueView::new();
let mut reg = IssueRegistry::new();
let id = reg.add_issue(make_issue("x", Severity::Error));
view.mode = ViewMode::Detail { id, scroll: 0 };
view.focus.set_focus(FOCUS_DETAIL);
let ops = view.handle_key(dummy_key(Key::F(12)));
assert!(ops.iter().any(|o| matches!(o, Operation::IssueViewLocal(IssueViewOp::OpenSelected))));
}
}