use ratatui::{
Frame,
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::Paragraph,
};
use crate::theme::Theme;
use crate::ui::styles;
use travelagent_core::forge::{PrListItem, PrState};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum StateFilter {
#[default]
All,
Open,
}
impl StateFilter {
pub fn cycle(self) -> Self {
match self {
StateFilter::All => StateFilter::Open,
StateFilter::Open => StateFilter::All,
}
}
fn matches(self, state: PrState) -> bool {
match self {
StateFilter::All => true,
StateFilter::Open => state == PrState::Open,
}
}
fn label(self) -> Option<&'static str> {
match self {
StateFilter::All => None,
StateFilter::Open => Some("open"),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PrListFilters {
pub assignee: Option<String>,
pub reviewer: Option<String>,
pub state: StateFilter,
}
impl PrListFilters {
pub fn is_active(&self) -> bool {
self.assignee.is_some() || self.reviewer.is_some() || self.state != StateFilter::All
}
pub fn clear(&mut self) {
self.assignee = None;
self.reviewer = None;
self.state = StateFilter::All;
}
fn matches(&self, item: &PrListItem) -> bool {
if !self.state.matches(item.state) {
return false;
}
if let Some(ref want) = self.assignee
&& !item.assignees.iter().any(|a| a.eq_ignore_ascii_case(want))
{
return false;
}
if let Some(ref want) = self.reviewer
&& !item.reviewers.iter().any(|r| r.eq_ignore_ascii_case(want))
{
return false;
}
true
}
}
pub fn apply_filters(rows: &[PrListItem], filters: &PrListFilters) -> Vec<PrListItem> {
rows.iter()
.filter(|r| filters.matches(r))
.cloned()
.collect()
}
pub fn status_bar_segment(filters: &PrListFilters) -> String {
if !filters.is_active() {
return String::new();
}
let mut parts: Vec<String> = Vec::new();
if let Some(ref a) = filters.assignee {
parts.push(format!("fa:{a}"));
}
if let Some(ref r) = filters.reviewer {
parts.push(format!("fr:{r}"));
}
if let Some(label) = filters.state.label() {
parts.push(format!("fs:{label}"));
}
format!("[{}]", parts.join(" "))
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum Mode {
#[default]
Normal,
FilterMenu,
PromptingAssignee(String),
PromptingReviewer(String),
}
#[derive(Debug, Default, Clone)]
pub struct PrListState {
pub cursor: usize,
pub scroll: usize,
pub filters: PrListFilters,
pub mode: Mode,
pub me_login: Option<String>,
}
fn resolve_me_token(raw: &str, me_login: Option<&str>) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
if trimmed.eq_ignore_ascii_case("@me")
&& let Some(me) = me_login
{
return Some(me.to_string());
}
Some(trimmed.to_string())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PrListEvent {
Nothing,
Redraw,
Select,
Quit,
}
impl PrListState {
pub fn new() -> Self {
Self::default()
}
pub fn clamp(&mut self, row_count: usize) {
if row_count == 0 {
self.cursor = 0;
self.scroll = 0;
return;
}
if self.cursor >= row_count {
self.cursor = row_count - 1;
}
if self.scroll > self.cursor {
self.scroll = self.cursor;
}
}
fn ensure_visible(&mut self, viewport_rows: usize) {
if viewport_rows == 0 {
return;
}
if self.cursor < self.scroll {
self.scroll = self.cursor;
} else if self.cursor >= self.scroll + viewport_rows {
self.scroll = self.cursor + 1 - viewport_rows;
}
}
fn reset_cursor(&mut self) {
self.cursor = 0;
self.scroll = 0;
}
}
pub fn handle_key(
key: crossterm::event::KeyEvent,
state: &mut PrListState,
row_count: usize,
viewport_rows: usize,
) -> PrListEvent {
use crossterm::event::{KeyCode, KeyModifiers};
let is_ctrl_c = key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL);
match std::mem::take(&mut state.mode) {
Mode::FilterMenu => return handle_filter_menu_key(key, state),
Mode::PromptingAssignee(buf) => {
if is_ctrl_c {
return PrListEvent::Redraw;
}
return handle_prompt_key(key, state, buf, PromptKind::Assignee);
}
Mode::PromptingReviewer(buf) => {
if is_ctrl_c {
return PrListEvent::Redraw;
}
return handle_prompt_key(key, state, buf, PromptKind::Reviewer);
}
Mode::Normal => {
}
}
if is_ctrl_c {
return PrListEvent::Quit;
}
match key.code {
KeyCode::Esc | KeyCode::Char('q') => PrListEvent::Quit,
KeyCode::Char('f') => {
state.mode = Mode::FilterMenu;
PrListEvent::Redraw
}
KeyCode::Enter | KeyCode::Char('o') => {
if row_count == 0 {
PrListEvent::Nothing
} else {
PrListEvent::Select
}
}
KeyCode::Down | KeyCode::Char('j') => {
if row_count == 0 || state.cursor + 1 >= row_count {
return PrListEvent::Nothing;
}
state.cursor += 1;
state.ensure_visible(viewport_rows);
PrListEvent::Redraw
}
KeyCode::Up | KeyCode::Char('k') => {
if state.cursor == 0 {
return PrListEvent::Nothing;
}
state.cursor -= 1;
state.ensure_visible(viewport_rows);
PrListEvent::Redraw
}
KeyCode::Char('g') => {
if state.cursor == 0 {
return PrListEvent::Nothing;
}
state.cursor = 0;
state.ensure_visible(viewport_rows);
PrListEvent::Redraw
}
KeyCode::Char('G') => {
if row_count == 0 {
return PrListEvent::Nothing;
}
let last = row_count - 1;
if state.cursor == last {
return PrListEvent::Nothing;
}
state.cursor = last;
state.ensure_visible(viewport_rows);
PrListEvent::Redraw
}
KeyCode::PageDown | KeyCode::Char('d') => {
if row_count == 0 {
return PrListEvent::Nothing;
}
let step = viewport_rows.max(1);
state.cursor = (state.cursor + step).min(row_count - 1);
state.ensure_visible(viewport_rows);
PrListEvent::Redraw
}
KeyCode::PageUp | KeyCode::Char('u') => {
if state.cursor == 0 {
return PrListEvent::Nothing;
}
let step = viewport_rows.max(1);
state.cursor = state.cursor.saturating_sub(step);
state.ensure_visible(viewport_rows);
PrListEvent::Redraw
}
_ => PrListEvent::Nothing,
}
}
#[derive(Copy, Clone)]
enum PromptKind {
Assignee,
Reviewer,
}
fn handle_filter_menu_key(key: crossterm::event::KeyEvent, state: &mut PrListState) -> PrListEvent {
use crossterm::event::KeyCode;
match key.code {
KeyCode::Char('a') => {
state.mode = Mode::PromptingAssignee(String::new());
PrListEvent::Redraw
}
KeyCode::Char('r') => {
state.mode = Mode::PromptingReviewer(String::new());
PrListEvent::Redraw
}
KeyCode::Char('s') => {
state.filters.state = state.filters.state.cycle();
state.reset_cursor();
PrListEvent::Redraw
}
KeyCode::Char('c') => {
state.filters.clear();
state.reset_cursor();
PrListEvent::Redraw
}
_ => PrListEvent::Redraw,
}
}
fn handle_prompt_key(
key: crossterm::event::KeyEvent,
state: &mut PrListState,
mut buf: String,
kind: PromptKind,
) -> PrListEvent {
use crossterm::event::KeyCode;
match key.code {
KeyCode::Esc => {
PrListEvent::Redraw
}
KeyCode::Enter => {
let value = resolve_me_token(&buf, state.me_login.as_deref());
match kind {
PromptKind::Assignee => state.filters.assignee = value,
PromptKind::Reviewer => state.filters.reviewer = value,
}
state.reset_cursor();
PrListEvent::Redraw
}
KeyCode::Backspace => {
buf.pop();
match kind {
PromptKind::Assignee => state.mode = Mode::PromptingAssignee(buf),
PromptKind::Reviewer => state.mode = Mode::PromptingReviewer(buf),
}
PrListEvent::Redraw
}
KeyCode::Char(c) => {
buf.push(c);
match kind {
PromptKind::Assignee => state.mode = Mode::PromptingAssignee(buf),
PromptKind::Reviewer => state.mode = Mode::PromptingReviewer(buf),
}
PrListEvent::Redraw
}
_ => {
match kind {
PromptKind::Assignee => state.mode = Mode::PromptingAssignee(buf),
PromptKind::Reviewer => state.mode = Mode::PromptingReviewer(buf),
}
PrListEvent::Nothing
}
}
}
pub fn row_line(item: &PrListItem, theme: &Theme, selected: bool) -> Line<'static> {
let marker = if item.is_draft { "D" } else { " " };
let state_label = match item.state {
PrState::Open => "open",
PrState::Closed => "closed",
PrState::Merged => "merged",
};
let state_color = match item.state {
PrState::Open => theme.pending,
PrState::Closed => theme.fg_dim,
PrState::Merged => theme.reviewed,
};
let prefix = format!(" {marker} #{:<6} ", item.number);
let middle = truncate_col(&item.title, 60);
let suffix = format!(" by {} ", item.author);
let state_part = format!("[{state_label}]");
let mut spans = vec![
Span::styled(prefix, Style::default().fg(theme.fg_secondary)),
Span::styled(middle, Style::default().add_modifier(Modifier::BOLD)),
Span::styled(suffix, Style::default().fg(theme.fg_secondary)),
Span::styled(state_part, Style::default().fg(state_color)),
];
if selected {
let sel = styles::selected_style(theme);
for span in &mut spans {
span.style = span.style.patch(sel);
}
}
Line::from(spans)
}
fn truncate_col(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return format!("{s:<max$}");
}
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
out.push('\u{2026}');
out
}
pub fn render(
frame: &mut Frame,
area: Rect,
rows: &[PrListItem],
state: &mut PrListState,
theme: &Theme,
header: &str,
) {
let filtered = apply_filters(rows, &state.filters);
let segment = status_bar_segment(&state.filters);
let header_suffix = if segment.is_empty() {
String::new()
} else {
format!(" {segment}")
};
let header_line = Line::from(vec![Span::styled(
format!(" {header}{header_suffix} (j/k move, Enter open, f filter, q cancel)"),
Style::default()
.fg(theme.fg_secondary)
.add_modifier(Modifier::BOLD),
)]);
let mut lines: Vec<Line<'static>> = Vec::with_capacity(filtered.len() + 3);
lines.push(header_line);
if let Some(prompt) = mode_prompt_line(&state.mode, theme) {
lines.push(prompt);
} else {
lines.push(Line::from(""));
}
state.clamp(filtered.len());
let viewport = (area.height as usize).saturating_sub(2);
if filtered.is_empty() {
lines.push(Line::from(Span::styled(
" No PRs match the current filter".to_string(),
styles::dim_style(theme),
)));
} else {
state.ensure_visible(viewport);
let end = (state.scroll + viewport).min(filtered.len());
for (idx, item) in filtered[state.scroll..end].iter().enumerate() {
let absolute = state.scroll + idx;
lines.push(row_line(item, theme, absolute == state.cursor));
}
}
let paragraph = Paragraph::new(lines).style(styles::panel_style(theme));
frame.render_widget(paragraph, area);
}
fn mode_prompt_line(mode: &Mode, theme: &Theme) -> Option<Line<'static>> {
let text = match mode {
Mode::Normal => return None,
Mode::FilterMenu => " filter: [a]ssignee [r]eviewer [s]tate [c]lear".to_string(),
Mode::PromptingAssignee(buf) => format!(" assignee (try @me): {buf}_"),
Mode::PromptingReviewer(buf) => format!(" reviewer (try @me): {buf}_"),
};
Some(Line::from(Span::styled(
text,
Style::default().fg(theme.fg_secondary),
)))
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use travelagent_core::forge::{PrListItem, PrState};
fn item(n: u64, title: &str, state: PrState) -> PrListItem {
PrListItem {
number: n,
title: title.to_string(),
author: "alice".to_string(),
state,
is_draft: false,
base_branch: "main".to_string(),
head_branch: "feature".to_string(),
updated_at: Utc::now(),
comment_count: 0,
has_review_requested_from_me: false,
assignees: vec![],
reviewers: vec![],
}
}
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::empty())
}
#[test]
fn j_moves_cursor_down_and_clamps_at_end() {
let mut s = PrListState::new();
let ev = handle_key(key(KeyCode::Char('j')), &mut s, 3, 10);
assert_eq!(ev, PrListEvent::Redraw);
assert_eq!(s.cursor, 1);
handle_key(key(KeyCode::Char('j')), &mut s, 3, 10);
handle_key(key(KeyCode::Char('j')), &mut s, 3, 10);
assert_eq!(s.cursor, 2);
let ev = handle_key(key(KeyCode::Char('j')), &mut s, 3, 10);
assert_eq!(ev, PrListEvent::Nothing);
assert_eq!(s.cursor, 2);
}
#[test]
fn k_moves_cursor_up_and_stops_at_zero() {
let mut s = PrListState::new();
s.cursor = 2;
handle_key(key(KeyCode::Char('k')), &mut s, 3, 10);
assert_eq!(s.cursor, 1);
handle_key(key(KeyCode::Char('k')), &mut s, 3, 10);
assert_eq!(s.cursor, 0);
let ev = handle_key(key(KeyCode::Char('k')), &mut s, 3, 10);
assert_eq!(ev, PrListEvent::Nothing);
}
#[test]
fn enter_selects_when_non_empty() {
let mut s = PrListState::new();
let ev = handle_key(key(KeyCode::Enter), &mut s, 3, 10);
assert_eq!(ev, PrListEvent::Select);
let ev = handle_key(key(KeyCode::Enter), &mut s, 0, 10);
assert_eq!(ev, PrListEvent::Nothing);
}
#[test]
fn esc_and_q_quit() {
let mut s = PrListState::new();
assert_eq!(
handle_key(key(KeyCode::Esc), &mut s, 3, 10),
PrListEvent::Quit
);
assert_eq!(
handle_key(key(KeyCode::Char('q')), &mut s, 3, 10),
PrListEvent::Quit
);
}
#[test]
fn ctrl_c_quits() {
let mut s = PrListState::new();
let ev = handle_key(
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
&mut s,
3,
10,
);
assert_eq!(ev, PrListEvent::Quit);
}
#[test]
fn g_jumps_to_top_capital_g_jumps_to_bottom() {
let mut s = PrListState::new();
s.cursor = 3;
handle_key(key(KeyCode::Char('g')), &mut s, 10, 5);
assert_eq!(s.cursor, 0);
handle_key(key(KeyCode::Char('G')), &mut s, 10, 5);
assert_eq!(s.cursor, 9);
}
#[test]
fn page_down_moves_by_viewport() {
let mut s = PrListState::new();
handle_key(key(KeyCode::PageDown), &mut s, 20, 5);
assert_eq!(s.cursor, 5);
handle_key(key(KeyCode::Char('d')), &mut s, 20, 5);
assert_eq!(s.cursor, 10);
}
#[test]
fn page_up_moves_by_viewport() {
let mut s = PrListState::new();
s.cursor = 15;
handle_key(key(KeyCode::PageUp), &mut s, 20, 5);
assert_eq!(s.cursor, 10);
handle_key(key(KeyCode::Char('u')), &mut s, 20, 5);
assert_eq!(s.cursor, 5);
}
#[test]
fn ensure_visible_scrolls_down_when_cursor_exits_viewport() {
let mut s = PrListState::new();
s.cursor = 12;
s.ensure_visible(5);
assert_eq!(s.scroll, 8);
}
#[test]
fn ensure_visible_scrolls_up_when_cursor_above_viewport() {
let mut s = PrListState::new();
s.scroll = 8;
s.cursor = 3;
s.ensure_visible(5);
assert_eq!(s.scroll, 3);
}
#[test]
fn clamp_handles_empty_and_shrink() {
let mut s = PrListState::new();
s.cursor = 10;
s.scroll = 5;
s.clamp(0);
assert_eq!(s.cursor, 0);
assert_eq!(s.scroll, 0);
s.cursor = 10;
s.clamp(3);
assert_eq!(s.cursor, 2);
}
#[test]
fn row_line_shows_draft_marker_and_state() {
let theme = Theme::dark();
let mut it = item(42, "feat: add login", PrState::Open);
it.is_draft = true;
let line = row_line(&it, &theme, false);
let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(text.contains('D'));
assert!(text.contains("#42"));
assert!(text.contains("feat: add login"));
assert!(text.contains("[open]"));
}
#[test]
fn truncate_col_adds_ellipsis_when_too_long() {
let s = truncate_col("a very long title that overflows", 10);
assert!(s.chars().count() <= 10);
assert!(s.ends_with('\u{2026}'));
}
#[test]
fn truncate_col_pads_short_values_to_width() {
let s = truncate_col("hi", 5);
assert_eq!(s, "hi ");
}
fn with_assignees(mut pr: PrListItem, assignees: &[&str]) -> PrListItem {
pr.assignees = assignees.iter().map(|s| s.to_string()).collect();
pr
}
fn with_reviewers(mut pr: PrListItem, reviewers: &[&str]) -> PrListItem {
pr.reviewers = reviewers.iter().map(|s| s.to_string()).collect();
pr
}
#[test]
fn filter_by_assignee_exact_match() {
let rows = vec![
with_assignees(item(1, "a", PrState::Open), &["john"]),
with_assignees(item(2, "b", PrState::Open), &["bob"]),
];
let filters = PrListFilters {
assignee: Some("john".to_string()),
..Default::default()
};
let got = apply_filters(&rows, &filters);
assert_eq!(got.len(), 1);
assert_eq!(got[0].number, 1);
}
#[test]
fn filter_by_assignee_is_case_insensitive() {
let rows = vec![with_assignees(item(1, "a", PrState::Open), &["John"])];
let filters = PrListFilters {
assignee: Some("john".to_string()),
..Default::default()
};
let got = apply_filters(&rows, &filters);
assert_eq!(got.len(), 1, "lowercased filter matches mixed-case login");
}
#[test]
fn filter_by_reviewer_is_case_insensitive() {
let rows = vec![with_reviewers(item(1, "a", PrState::Open), &["ALICE"])];
let filters = PrListFilters {
reviewer: Some("alice".to_string()),
..Default::default()
};
let got = apply_filters(&rows, &filters);
assert_eq!(got.len(), 1);
}
#[test]
fn filter_by_reviewer_exact_match() {
let rows = vec![
with_reviewers(item(1, "a", PrState::Open), &["alice"]),
with_reviewers(item(2, "b", PrState::Open), &["carol"]),
];
let filters = PrListFilters {
reviewer: Some("alice".to_string()),
..Default::default()
};
let got = apply_filters(&rows, &filters);
assert_eq!(got.len(), 1);
assert_eq!(got[0].number, 1);
}
#[test]
fn filter_by_state_open_excludes_closed() {
let rows = vec![
item(1, "open", PrState::Open),
item(2, "closed", PrState::Closed),
item(3, "merged", PrState::Merged),
];
let filters = PrListFilters {
state: StateFilter::Open,
..Default::default()
};
let got = apply_filters(&rows, &filters);
assert_eq!(got.len(), 1);
assert_eq!(got[0].number, 1);
}
#[test]
fn filter_clear_restores_full_list() {
let rows = vec![
with_assignees(item(1, "a", PrState::Open), &["john"]),
item(2, "b", PrState::Closed),
];
let mut filters = PrListFilters {
assignee: Some("john".to_string()),
state: StateFilter::Open,
..Default::default()
};
assert_eq!(apply_filters(&rows, &filters).len(), 1);
filters.clear();
assert!(!filters.is_active());
assert_eq!(apply_filters(&rows, &filters).len(), rows.len());
}
#[test]
fn status_bar_segment_empty_when_no_filter() {
let filters = PrListFilters::default();
assert_eq!(status_bar_segment(&filters), "");
}
#[test]
fn status_bar_segment_shows_active_filters() {
let filters = PrListFilters {
assignee: Some("john".to_string()),
reviewer: Some("alice".to_string()),
state: StateFilter::Open,
};
assert_eq!(status_bar_segment(&filters), "[fa:john fr:alice fs:open]");
}
#[test]
fn f_s_cycles_state_filter() {
let mut s = PrListState::new();
handle_key(key(KeyCode::Char('f')), &mut s, 0, 10);
assert_eq!(s.mode, Mode::FilterMenu);
handle_key(key(KeyCode::Char('s')), &mut s, 0, 10);
assert_eq!(s.mode, Mode::Normal);
assert_eq!(s.filters.state, StateFilter::Open);
handle_key(key(KeyCode::Char('f')), &mut s, 0, 10);
handle_key(key(KeyCode::Char('s')), &mut s, 0, 10);
assert_eq!(s.filters.state, StateFilter::All);
}
#[test]
fn f_a_then_input_then_enter_sets_assignee() {
let mut s = PrListState::new();
handle_key(key(KeyCode::Char('f')), &mut s, 0, 10);
handle_key(key(KeyCode::Char('a')), &mut s, 0, 10);
assert_eq!(s.mode, Mode::PromptingAssignee(String::new()));
for c in "john".chars() {
handle_key(key(KeyCode::Char(c)), &mut s, 0, 10);
}
handle_key(key(KeyCode::Enter), &mut s, 0, 10);
assert_eq!(s.filters.assignee.as_deref(), Some("john"));
assert_eq!(s.mode, Mode::Normal);
}
#[test]
fn f_c_clears_filters() {
let mut s = PrListState::new();
s.filters.assignee = Some("john".to_string());
s.filters.state = StateFilter::Open;
handle_key(key(KeyCode::Char('f')), &mut s, 0, 10);
handle_key(key(KeyCode::Char('c')), &mut s, 0, 10);
assert!(!s.filters.is_active());
}
#[test]
fn resolve_me_token_expands_literal_at_me() {
assert_eq!(
resolve_me_token("@me", Some("john")),
Some("john".to_string())
);
assert_eq!(
resolve_me_token(" @ME ", Some("john")),
Some("john".to_string()),
"case-insensitive and whitespace-trimmed"
);
}
#[test]
fn resolve_me_token_passes_through_regular_login() {
assert_eq!(
resolve_me_token("alice", Some("john")),
Some("alice".to_string())
);
}
#[test]
fn resolve_me_token_keeps_literal_when_me_unknown() {
assert_eq!(
resolve_me_token("@me", None),
Some("@me".to_string()),
"unknown me falls through unchanged"
);
}
#[test]
fn resolve_me_token_returns_none_for_empty_buffer() {
assert_eq!(resolve_me_token("", Some("john")), None);
assert_eq!(resolve_me_token(" ", Some("john")), None);
}
#[test]
fn f_a_at_me_expands_to_resolved_login() {
let mut s = PrListState::new();
s.me_login = Some("john".to_string());
handle_key(key(KeyCode::Char('f')), &mut s, 0, 10);
handle_key(key(KeyCode::Char('a')), &mut s, 0, 10);
for c in "@me".chars() {
handle_key(key(KeyCode::Char(c)), &mut s, 0, 10);
}
handle_key(key(KeyCode::Enter), &mut s, 0, 10);
assert_eq!(s.filters.assignee.as_deref(), Some("john"));
}
#[test]
fn f_r_at_me_expands_to_resolved_login() {
let mut s = PrListState::new();
s.me_login = Some("john".to_string());
handle_key(key(KeyCode::Char('f')), &mut s, 0, 10);
handle_key(key(KeyCode::Char('r')), &mut s, 0, 10);
for c in "@me".chars() {
handle_key(key(KeyCode::Char(c)), &mut s, 0, 10);
}
handle_key(key(KeyCode::Enter), &mut s, 0, 10);
assert_eq!(s.filters.reviewer.as_deref(), Some("john"));
}
#[test]
fn esc_in_prompt_cancels_without_setting() {
let mut s = PrListState::new();
handle_key(key(KeyCode::Char('f')), &mut s, 0, 10);
handle_key(key(KeyCode::Char('r')), &mut s, 0, 10);
handle_key(key(KeyCode::Char('x')), &mut s, 0, 10);
handle_key(key(KeyCode::Esc), &mut s, 0, 10);
assert!(s.filters.reviewer.is_none());
assert_eq!(s.mode, Mode::Normal);
}
#[test]
fn selection_uses_filtered_view_after_assignee_filter() {
let rows = vec![
with_assignees(item(1, "unrelated", PrState::Open), &["alice"]),
with_assignees(item(2, "mine", PrState::Open), &["john"]),
with_assignees(item(3, "other", PrState::Open), &["bob"]),
];
let filters = PrListFilters {
assignee: Some("john".into()),
..Default::default()
};
let visible = apply_filters(&rows, &filters);
assert_eq!(visible.len(), 1, "exactly one row matches john");
let cursor = 0usize;
let selected = visible.get(cursor).expect("cursor within filtered bounds");
assert_eq!(
selected.number, 2,
"Select must index the filtered view, not the unfiltered rows slice"
);
assert_ne!(
rows[cursor].number, 2,
"sanity — this is exactly the bug the fix prevents"
);
}
#[test]
fn ctrl_c_in_assignee_prompt_cancels_prompt_not_picker() {
let mut s = PrListState::new();
handle_key(key(KeyCode::Char('f')), &mut s, 0, 10);
handle_key(key(KeyCode::Char('a')), &mut s, 0, 10);
for c in "jo".chars() {
handle_key(key(KeyCode::Char(c)), &mut s, 0, 10);
}
let ev = handle_key(
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
&mut s,
0,
10,
);
assert_eq!(ev, PrListEvent::Redraw, "Ctrl+C in prompt must not Quit");
assert_eq!(s.mode, Mode::Normal, "prompt should have exited");
assert!(
s.filters.assignee.is_none(),
"buffer must not be committed on cancel"
);
}
#[test]
fn ctrl_c_in_reviewer_prompt_cancels_prompt_not_picker() {
let mut s = PrListState::new();
handle_key(key(KeyCode::Char('f')), &mut s, 0, 10);
handle_key(key(KeyCode::Char('r')), &mut s, 0, 10);
handle_key(key(KeyCode::Char('x')), &mut s, 0, 10);
let ev = handle_key(
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
&mut s,
0,
10,
);
assert_eq!(ev, PrListEvent::Redraw);
assert_eq!(s.mode, Mode::Normal);
assert!(s.filters.reviewer.is_none());
}
#[test]
fn ctrl_c_in_normal_mode_still_quits() {
let mut s = PrListState::new();
let ev = handle_key(
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
&mut s,
3,
10,
);
assert_eq!(ev, PrListEvent::Quit);
}
#[test]
fn selection_noops_when_cursor_past_filtered_end() {
let rows = vec![item(1, "a", PrState::Open), item(2, "b", PrState::Open)];
let filters = PrListFilters {
assignee: Some("nobody".into()),
..Default::default()
};
let visible = apply_filters(&rows, &filters);
assert!(
visible.is_empty(),
"filter matches nothing; Select must not open any row"
);
}
}