use anyhow::Context;
use crossterm::{
event::{self, Event, KeyCode, KeyEventKind},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
Terminal,
backend::CrosstermBackend,
layout::{Alignment, Constraint, Direction, Flex, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState, Wrap},
};
#[allow(unused_imports)]
use ratatui::layout::Position;
#[allow(unused_imports)]
use ratatui::widgets::Clear;
use std::sync::mpsc;
use std::time::{Duration, Instant};
use crate::models::{CiStatus, PullRequest, ReviewStatus};
pub fn run_interactive(
rx: mpsc::Receiver<(Vec<PullRequest>, Vec<String>)>,
config: &crate::config::Config,
initial_profile: Option<usize>,
) -> anyhow::Result<()> {
use std::io::IsTerminal;
if !std::io::stdout().is_terminal() {
drop(rx.recv());
return Ok(());
}
enable_raw_mode().context("failed to enable raw mode")?;
let mut stdout = std::io::stdout();
execute!(stdout, EnterAlternateScreen).context("failed to enter alternate screen")?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend).context("failed to create terminal")?;
let result = run_tui_loop(&mut terminal, rx, config, initial_profile);
disable_raw_mode().context("failed to disable raw mode")?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)
.context("failed to leave alternate screen")?;
terminal.show_cursor().context("failed to show cursor")?;
result
}
#[derive(Default, PartialEq)]
enum AppMode {
#[default]
Normal,
Searching,
ShowingDetail,
}
fn enter_char(s: &mut String, cursor: &mut usize, c: char) {
let byte_pos = s.char_indices().nth(*cursor).map(|(i, _)| i).unwrap_or(s.len());
s.insert(byte_pos, c);
*cursor = (*cursor + 1).min(s.chars().count());
}
fn delete_char(s: &mut String, cursor: &mut usize) {
if *cursor == 0 {
return;
}
let before: String = s.chars().take(*cursor - 1).collect();
let after: String = s.chars().skip(*cursor).collect();
*s = before + &after;
*cursor = cursor.saturating_sub(1);
}
#[allow(dead_code)] fn popup_area(area: Rect, percent_x: u16, percent_y: u16) -> Rect {
let vertical = Layout::vertical([Constraint::Percentage(percent_y)]).flex(Flex::Center);
let horizontal = Layout::horizontal([Constraint::Percentage(percent_x)]).flex(Flex::Center);
let [area] = vertical.areas(area);
let [area] = horizontal.areas(area);
area
}
fn build_detail_block<'a>(
pr: &'a crate::models::PullRequest,
jira: &crate::config::JiraConfig,
panel_blue: Color,
hint_color: Color,
) -> Paragraph<'a> {
let jira_enabled = jira.base_url.is_some();
let lbl = Style::default().fg(panel_blue); let val = Style::default().fg(Color::Rgb(205, 214, 244)); let add_color = Color::Rgb(166, 227, 161); let del_color = Color::Rgb(243, 139, 168); let url_style = Style::default()
.fg(Color::Rgb(116, 199, 236))
.add_modifier(Modifier::UNDERLINED);
let ci_color = match &pr.ci_status {
Some(CiStatus::Success) => Color::Rgb(166, 227, 161),
Some(CiStatus::Failed) => Color::Rgb(243, 139, 168),
Some(CiStatus::Pending) | Some(CiStatus::Running) => Color::Rgb(249, 226, 175),
None | Some(CiStatus::Cancelled) => Color::Rgb(88, 91, 112),
};
let review_color = match &pr.review_status {
ReviewStatus::NeedsReview => Color::Rgb(250, 179, 135),
ReviewStatus::Approved => Color::Rgb(166, 227, 161),
ReviewStatus::ChangesRequested => Color::Rgb(243, 139, 168),
ReviewStatus::Mixed => Color::Rgb(249, 226, 175),
ReviewStatus::InReview => Color::Rgb(137, 220, 235),
};
let provider_icon = match pr.provider.as_str() {
"github" => "\u{E709} github",
"bitbucket" => "\u{E703} bitbucket",
other => other,
};
let labels_joined = if pr.labels.is_empty() {
"(none)".to_string()
} else {
pr.labels.join(", ")
};
let additions = pr.additions.map(|n| n.to_string()).unwrap_or_else(|| "?".to_string());
let deletions = pr.deletions.map(|n| n.to_string()).unwrap_or_else(|| "?".to_string());
let mut lines: Vec<Line<'a>> = vec![
Line::from(vec![
Span::styled("Title: ", lbl),
Span::styled(pr.title.clone(), val.add_modifier(Modifier::BOLD)),
]),
Line::from(vec![
Span::styled("Author: ", lbl),
Span::styled(pr.author.login.clone(), val),
]),
Line::from(vec![
Span::styled("URL: ", lbl),
Span::styled(pr.url.clone(), url_style),
]),
Line::from(vec![
Span::styled("Branch: ", lbl),
Span::styled(format!("{} \u{2192} {}", pr.head_branch, pr.base_branch), val),
]),
Line::from(vec![
Span::styled("Labels: ", lbl),
Span::styled(labels_joined, val),
]),
Line::from(vec![
Span::styled("Comments: ", lbl),
Span::styled(pr.comment_count.to_string(), val),
]),
Line::from(vec![
Span::styled("Changes: ", lbl),
Span::styled(format!("+{additions}"), Style::default().fg(add_color)),
Span::styled(" / ", val),
Span::styled(format!("-{deletions}"), Style::default().fg(del_color)),
]),
Line::from(vec![
Span::styled("Review: ", lbl),
Span::styled(format_review_status(&pr.review_status), Style::default().fg(review_color)),
]),
Line::from(vec![
Span::styled("CI: ", lbl),
Span::styled(
format!("{} {}", format_ci_status(&pr.ci_status),
match &pr.ci_status {
Some(CiStatus::Success) => "passing",
Some(CiStatus::Failed) => "failed",
Some(CiStatus::Pending) => "pending",
Some(CiStatus::Running) => "running",
Some(CiStatus::Cancelled) | None => "—",
}
),
Style::default().fg(ci_color),
),
]),
Line::from(vec![
Span::styled("Age: ", lbl),
Span::styled(format_age(&pr.updated_at), val),
]),
Line::from(vec![
Span::styled("Provider: ", lbl),
Span::styled(provider_icon, val),
]),
];
if jira_enabled {
if let Some(key) = extract_jira_key(&pr.title) {
lines.push(Line::from(vec![
Span::styled("Jira: ", lbl),
Span::styled(format!("{key} (J to open)"), val),
]));
}
}
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(panel_blue))
.title(Line::from(Span::styled(
" PR Detail ",
Style::default().fg(panel_blue).add_modifier(Modifier::BOLD),
)))
.title_bottom(Line::from(Span::styled(
" Esc / Space to close ",
Style::default().fg(hint_color),
)));
Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false })
}
#[allow(dead_code)] fn extract_jira_key(title: &str) -> Option<String> {
let chars: Vec<char> = title.chars().collect();
let mut i = 0;
while i < chars.len() {
if chars[i].is_ascii_uppercase() {
let key_start = i;
while i < chars.len() && chars[i].is_ascii_uppercase() {
i += 1;
}
let key_len = i - key_start;
if key_len >= 1 && i < chars.len() && chars[i] == '-' {
i += 1; let num_start = i;
while i < chars.len() && chars[i].is_ascii_digit() {
i += 1;
}
if i > num_start {
return Some(chars[key_start..i].iter().collect());
}
}
} else {
i += 1;
}
}
None
}
fn compute_displayed_indices(
rows_data: &[[String; 9]],
repo_full_names: &[String],
pr_providers: &[String],
is_draft: &[bool],
show_drafts: bool,
search_query: &str,
profiles_filter: Option<&crate::config::ProfileConfig>,
) -> Vec<usize> {
rows_data
.iter()
.enumerate()
.filter(|(idx, cells)| {
if !show_drafts && *idx < is_draft.len() && is_draft[*idx] {
return false;
}
if let Some(profile) = profiles_filter {
if !profile.filters.is_empty() {
let full = repo_full_names.get(*idx).map(|s| s.to_lowercase()).unwrap_or_default();
let prov = pr_providers.get(*idx).map(|s| s.to_lowercase()).unwrap_or_default();
let status_cell = cells[5].to_lowercase();
let matches_any = profile.filters.iter().any(|fg| {
if let Some(p) = &fg.provider {
if !p.is_empty() && prov != p.to_lowercase() { return false; }
}
if let Some(o) = &fg.org {
if !o.is_empty() {
let org_part = full.split('/').next().unwrap_or("");
if !org_part.contains(&o.to_lowercase()) { return false; }
}
}
if let Some(r) = &fg.repo {
if !r.is_empty() && !full.contains(&r.to_lowercase()) { return false; }
}
if let Some(s) = &fg.status {
if !s.is_empty() && !status_cell.contains(&s.to_lowercase()) { return false; }
}
true
});
if !matches_any {
return false;
}
}
}
if search_query.is_empty() {
return true;
}
let q = search_query.to_lowercase();
cells[2].to_lowercase().contains(&q) || cells[3].to_lowercase().contains(&q) || cells[4].to_lowercase().contains(&q) })
.map(|(idx, _)| idx)
.collect()
}
fn run_tui_loop(
terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>,
rx: mpsc::Receiver<(Vec<PullRequest>, Vec<String>)>,
config: &crate::config::Config,
initial_profile: Option<usize>,
) -> anyhow::Result<()> {
let header_style = Style::default()
.add_modifier(Modifier::BOLD)
.fg(Color::Rgb(203, 166, 247)); let selected_style = Style::default()
.bg(Color::Rgb(203, 166, 247))
.fg(Color::Rgb(30, 30, 46))
.add_modifier(Modifier::BOLD);
let ci_color_success = Color::Rgb(166, 227, 161);
let ci_color_failed = Color::Rgb(243, 139, 168);
let ci_color_pending = Color::Rgb(249, 226, 175);
let ci_color_none = Color::Rgb(88, 91, 112);
let status_color_needs_review = Color::Rgb(250, 179, 135);
let status_color_approved = Color::Rgb(166, 227, 161);
let status_color_changes = Color::Rgb(243, 139, 168);
let status_color_mixed = Color::Rgb(249, 226, 175);
let status_color_in_review = Color::Rgb(137, 220, 235);
let draft_icon_color = Color::Rgb(116, 199, 236);
let panel_blue = Color::Rgb(137, 180, 250);
let hint_color = Color::Rgb(116, 199, 236);
const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
let mut spinner_tick: usize = 0;
let mut last_tick = Instant::now();
let error_color = Color::Rgb(243, 139, 168);
let mut prs: Option<Vec<PullRequest>> = None;
let mut provider_errors: Vec<String> = Vec::new();
let mut rows_data: Vec<[String; 9]> = Vec::new();
let mut repo_full_names: Vec<String> = Vec::new();
let mut pr_providers: Vec<String> = Vec::new();
let mut ci_styles: Vec<Style> = Vec::new();
let mut status_styles: Vec<Style> = Vec::new();
let mut is_draft: Vec<bool> = Vec::new();
let mut table_state = TableState::default();
let mut mode = AppMode::Normal;
let mut search_query = String::new();
let mut cursor_pos: usize = 0;
let mut show_drafts: bool = false;
let mut active_profile_idx: Option<usize> = initial_profile;
let mut scroll_offset: usize = 0;
let mut last_scroll_tick = Instant::now();
const SCROLL_INTERVAL_MS: u64 = 200; let mut last_selected: Option<usize> = None;
let mut last_displayed_len: usize = 0;
let mut displayed_snapshot: Vec<usize> = Vec::new();
loop {
if prs.is_none() {
match rx.try_recv() {
Ok((loaded, errors)) => {
provider_errors = errors;
for pr in &loaded {
let src = match pr.provider.as_str() {
"github" => "\u{E709}".to_string(),
"bitbucket" => "\u{E703}".to_string(),
other => other.to_string(),
};
rows_data.push([
src,
pr.number.to_string(),
pr.title.clone(),
pr.author.login.clone(),
pr.repo_full_name.split('/').last()
.unwrap_or(&pr.repo_full_name).to_string(),
format_review_status(&pr.review_status).to_string(),
format_ci_status(&pr.ci_status).to_string(),
pr.reviewers.len().to_string(),
format_age(&pr.updated_at),
]);
repo_full_names.push(pr.repo_full_name.clone());
pr_providers.push(pr.provider.clone());
is_draft.push(pr.draft);
ci_styles.push(Style::default().fg(match &pr.ci_status {
Some(CiStatus::Success) => ci_color_success,
Some(CiStatus::Failed) => ci_color_failed,
Some(CiStatus::Pending) | Some(CiStatus::Running) => ci_color_pending,
None | Some(CiStatus::Cancelled) => ci_color_none,
}));
status_styles.push(Style::default().fg(match &pr.review_status {
ReviewStatus::NeedsReview => status_color_needs_review,
ReviewStatus::Approved => status_color_approved,
ReviewStatus::ChangesRequested => status_color_changes,
ReviewStatus::Mixed => status_color_mixed,
ReviewStatus::InReview => status_color_in_review,
}));
}
if !loaded.is_empty() {
table_state.select(Some(0));
}
prs = Some(loaded);
}
Err(mpsc::TryRecvError::Empty) => {
if last_tick.elapsed() >= Duration::from_millis(100) {
spinner_tick = (spinner_tick + 1) % SPINNER.len();
last_tick = Instant::now();
}
}
Err(mpsc::TryRecvError::Disconnected) => {
return Err(anyhow::anyhow!("Provider fetch task ended unexpectedly"));
}
}
}
let current_sel = table_state.selected();
if current_sel != last_selected {
scroll_offset = 0;
last_selected = current_sel;
}
if prs.is_some() && last_scroll_tick.elapsed() >= Duration::from_millis(SCROLL_INTERVAL_MS) {
scroll_offset = scroll_offset.wrapping_add(1);
last_scroll_tick = Instant::now();
}
terminal.draw(|frame| {
let area = frame.area();
match &prs {
None => {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(panel_blue))
.title(Line::from(Span::styled(
" prlens ",
Style::default().fg(panel_blue).add_modifier(Modifier::BOLD),
)))
.title_bottom(Line::from(Span::styled(
" q to cancel ",
Style::default().fg(hint_color),
)));
let inner = block.inner(area);
frame.render_widget(block, area);
let [_, mid, _] = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Fill(1), Constraint::Length(1), Constraint::Fill(1)])
.areas(inner);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
SPINNER[spinner_tick],
Style::default().fg(panel_blue).add_modifier(Modifier::BOLD),
),
Span::raw(" Fetching pull requests…"),
]))
.alignment(Alignment::Center),
mid,
);
}
Some(loaded) if loaded.is_empty() => {
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(panel_blue))
.title(Line::from(Span::styled(
" prlens — 0 PRs waiting ",
Style::default().fg(panel_blue).add_modifier(Modifier::BOLD),
)))
.title_bottom(Line::from(Span::styled(
" q to exit ",
Style::default().fg(hint_color),
)));
let inner = block.inner(area);
frame.render_widget(block, area);
let [_, mid, _] = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Fill(1), Constraint::Length(1), Constraint::Fill(1)])
.areas(inner);
frame.render_widget(
Paragraph::new("No pull requests found.").alignment(Alignment::Center),
mid,
);
}
Some(_) => {
let (table_area, error_area) = if provider_errors.is_empty() {
(area, None)
} else {
let usable_width = area.width.saturating_sub(3).max(1) as usize; let total_lines: u16 = provider_errors.iter()
.map(|e| ((e.chars().count() + usable_width - 1) / usable_width).max(1) as u16)
.sum();
let [top, bot] = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Fill(1), Constraint::Length(total_lines)])
.areas(area);
(top, Some(bot))
};
let area = table_area;
let (table_area, search_bar_area) = if mode == AppMode::Searching {
let [top, bot] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(3),
]).areas(area);
(top, Some(bot))
} else {
(area, None)
};
let area = table_area;
let widths = [
Constraint::Length(3), Constraint::Length(6), Constraint::Fill(3), Constraint::Fill(1), Constraint::Fill(1), Constraint::Min(5), Constraint::Length(3), Constraint::Length(4), Constraint::Length(5), ];
let header = Row::new(["Src", "#", "Title", "Author", "Repo", "Status", "CI", "Rev.", "Age"])
.style(header_style);
let title_col_width = {
let inner = Rect { x: 0, y: 0, width: area.width.saturating_sub(2), height: 1 };
let mut lc: Vec<Constraint> = Vec::with_capacity(widths.len() * 2 - 1);
for (i, w) in widths.iter().enumerate() {
if i > 0 { lc.push(Constraint::Length(1)); }
lc.push(*w);
}
Layout::horizontal(lc).split(inner)[4].width as usize
};
let active_profile = active_profile_idx.and_then(|i| config.profiles.get(i));
let displayed_indices =
compute_displayed_indices(&rows_data, &repo_full_names, &pr_providers, &is_draft, show_drafts, &search_query, active_profile);
let rows: Vec<Row> = displayed_indices
.iter()
.enumerate()
.map(|(display_pos, &raw_idx)| {
let cells = &rows_data[raw_idx];
let is_selected = table_state.selected() == Some(display_pos);
let title_str = &cells[2];
let title_chars = title_str.chars().count();
let gap = " "; let padded: String = format!("{}{}", title_str, gap);
let cycle_len = padded.chars().count();
let needs_scroll = is_selected && title_chars > title_col_width;
let offset = if needs_scroll { scroll_offset % cycle_len } else { 0 };
let scrolled_title: String = padded
.chars()
.cycle()
.skip(offset)
.take(title_str.chars().count() + gap.len())
.collect();
let styled: Vec<Cell> = cells.iter().enumerate().map(|(i, c)| {
match i {
2 if is_selected && is_draft[raw_idx] => Cell::from(Line::from(vec![
Span::styled("\u{F040} ", Style::default().fg(draft_icon_color)),
Span::raw(scrolled_title.clone()),
])),
2 if is_selected => Cell::from(scrolled_title.clone()),
2 if is_draft[raw_idx] => Cell::from(Line::from(vec![
Span::styled("\u{F040} ", Style::default().fg(draft_icon_color)),
Span::raw(c.clone()),
])),
5 => Cell::from(c.as_str()).style(status_styles[raw_idx]),
6 => Cell::from(c.as_str()).style(ci_styles[raw_idx]),
_ => Cell::from(c.as_str()),
}
}).collect();
Row::new(styled)
})
.collect();
let visible_count = displayed_indices.len();
let title_line = {
let base_text = format!(
" prlens — {visible_count} PR{} waiting ",
if visible_count == 1 { "" } else { "s" },
);
let mut spans = vec![
Span::styled(base_text, Style::default().fg(panel_blue).add_modifier(Modifier::BOLD)),
];
if let Some(idx) = active_profile_idx {
if let Some(p) = config.profiles.get(idx) {
spans.push(Span::styled(
format!(" {} ", p.name),
Style::default()
.fg(Color::Rgb(30, 30, 46))
.bg(Color::Rgb(166, 227, 161))
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(" ", Style::default().fg(panel_blue)));
}
}
let (draft_label, draft_fg, draft_bg) = if show_drafts {
(" drafts on ", Color::Rgb(30, 30, 46), Color::Rgb(249, 226, 175))
} else {
(" drafts off ", Color::Rgb(205, 214, 244), Color::Rgb(88, 91, 112))
};
spans.push(Span::styled(
draft_label,
Style::default().fg(draft_fg).bg(draft_bg).add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(" ", Style::default().fg(panel_blue)));
Line::from(spans)
};
let key_style = Style::default().fg(Color::Rgb(205, 214, 244)).add_modifier(Modifier::BOLD);
let desc_style = Style::default().fg(hint_color);
let draft_label = if show_drafts { " hide drafts " } else { " show drafts " };
let mut hint_spans = vec![
Span::styled(" /", key_style), Span::styled(" search ", desc_style),
Span::styled("Space", key_style), Span::styled(" detail ", desc_style),
Span::styled("d", key_style), Span::styled(draft_label, desc_style),
Span::styled("j/k", key_style), Span::styled(" nav ", desc_style),
Span::styled("Enter", key_style), Span::styled(" open ", desc_style),
Span::styled("q", key_style), Span::styled(" quit ", desc_style),
];
if config.jira.base_url.is_some() {
hint_spans.insert(6, Span::styled("J", key_style));
hint_spans.insert(7, Span::styled(" jira ", desc_style));
}
if !config.profiles.is_empty() {
hint_spans.insert(6, Span::styled("Tab", key_style));
let profile_hint_label = match active_profile_idx.and_then(|i| config.profiles.get(i)) {
Some(p) => format!(" [{}] ", p.name),
None => " profile ".to_string(),
};
hint_spans.insert(7, Span::styled(profile_hint_label, desc_style));
}
let hint_line = Line::from(hint_spans);
let table = Table::new(rows, widths)
.header(header)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(panel_blue))
.title(title_line)
.title_bottom(hint_line),
)
.row_highlight_style(selected_style);
frame.render_stateful_widget(table, area, &mut table_state);
if mode == AppMode::ShowingDetail {
if let Some(display_i) = table_state.selected() {
if let Some(&raw_i) = displayed_snapshot.get(display_i) {
if let Some(pr) = prs.as_ref().and_then(|v| v.get(raw_i)) {
let overlay_area = popup_area(area, 70, 80);
frame.render_widget(Clear, overlay_area);
frame.render_widget(
build_detail_block(pr, &config.jira, panel_blue, hint_color),
overlay_area,
);
}
}
}
}
if mode == AppMode::Searching {
if let Some(sa) = search_bar_area {
let input_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(panel_blue))
.title(Line::from(Span::styled(
" Search ",
Style::default().fg(panel_blue),
)));
let input_paragraph = Paragraph::new(search_query.as_str())
.block(input_block);
frame.render_widget(input_paragraph, sa);
frame.set_cursor_position(Position::new(
sa.x + cursor_pos as u16 + 1, sa.y + 1, ));
}
}
displayed_snapshot = displayed_indices.clone();
last_displayed_len = displayed_indices.len();
if let Some(err_area) = error_area {
let lines: Vec<Line> = provider_errors.iter()
.map(|e| Line::from(Span::styled(
format!(" ⚠ {e}"),
Style::default().fg(error_color),
)))
.collect();
frame.render_widget(
Paragraph::new(lines).wrap(Wrap { trim: false }),
err_area,
);
}
}
}
})?;
let timeout = if prs.is_none() || mode == AppMode::Searching {
Duration::from_millis(50)
} else {
Duration::from_millis(200)
};
if event::poll(timeout)? {
if let Event::Key(key) = event::read()? {
if key.kind != KeyEventKind::Press { continue; }
let pr_count = prs.as_ref().map_or(0, |v| v.len());
match mode {
AppMode::Normal => match key.code {
KeyCode::Char('q') | KeyCode::Esc => break,
KeyCode::Char('/') => {
mode = AppMode::Searching;
search_query.clear();
cursor_pos = 0;
}
KeyCode::Char(' ') if last_displayed_len > 0 => {
mode = AppMode::ShowingDetail;
}
KeyCode::Char('d') => {
show_drafts = !show_drafts;
let new_len = compute_displayed_indices(
&rows_data, &repo_full_names, &pr_providers, &is_draft, show_drafts, &search_query,
active_profile_idx.and_then(|i| config.profiles.get(i)),
).len();
if let Some(sel) = table_state.selected() {
if sel >= new_len && new_len > 0 {
table_state.select(Some(new_len - 1));
} else if new_len == 0 {
table_state.select(None);
}
}
}
KeyCode::Tab if !config.profiles.is_empty() => {
active_profile_idx = match active_profile_idx {
None => Some(0),
Some(i) if i + 1 < config.profiles.len() => Some(i + 1),
Some(_) => None,
};
let new_profile = active_profile_idx.and_then(|i| config.profiles.get(i));
let new_len = compute_displayed_indices(
&rows_data, &repo_full_names, &pr_providers, &is_draft, show_drafts, &search_query, new_profile,
).len();
if new_len == 0 {
table_state.select(None);
} else {
table_state.select(Some(0));
}
}
KeyCode::Char('J') if last_displayed_len > 0 => {
if let Some(ref base_url) = config.jira.base_url {
if let Some(display_i) = table_state.selected() {
let raw_i_opt = displayed_snapshot.get(display_i).copied()
.or_else(|| prs.as_ref().and_then(|v| if display_i < v.len() { Some(display_i) } else { None }));
if let Some(raw_i) = raw_i_opt {
if let Some(pr) = prs.as_ref().and_then(|v| v.get(raw_i)) {
if let Some(key_str) = extract_jira_key(&pr.title) {
let jira_url = format!("{}/browse/{}", base_url.trim_end_matches('/'), key_str);
let _ = open::that(jira_url);
}
}
}
}
}
}
KeyCode::Char('j') | KeyCode::Down if last_displayed_len > 0 => {
let len = last_displayed_len;
let next = match table_state.selected() {
Some(i) if i + 1 < len => i + 1,
_ => 0,
};
table_state.select(Some(next));
}
KeyCode::Char('k') | KeyCode::Up if last_displayed_len > 0 => {
let len = last_displayed_len;
let prev = match table_state.selected() {
Some(0) | None => len.saturating_sub(1),
Some(i) => i - 1,
};
table_state.select(Some(prev));
}
KeyCode::Enter if last_displayed_len > 0 => {
if let Some(display_i) = table_state.selected() {
if let Some(&raw_i) = displayed_snapshot.get(display_i) {
if let Some(pr) = prs.as_ref().and_then(|v| v.get(raw_i)) {
let _ = open::that(&pr.url);
}
}
}
}
_ => {}
},
AppMode::Searching => match key.code {
KeyCode::Esc => {
mode = AppMode::Normal;
search_query.clear();
cursor_pos = 0;
let new_len = compute_displayed_indices(
&rows_data, &repo_full_names, &pr_providers, &is_draft, show_drafts, &search_query,
active_profile_idx.and_then(|i| config.profiles.get(i)),
).len();
if let Some(sel) = table_state.selected() {
if sel >= new_len && new_len > 0 {
table_state.select(Some(new_len - 1));
} else if new_len == 0 {
table_state.select(None);
}
}
}
KeyCode::Backspace => {
delete_char(&mut search_query, &mut cursor_pos);
let new_len = compute_displayed_indices(
&rows_data, &repo_full_names, &pr_providers, &is_draft, show_drafts, &search_query,
active_profile_idx.and_then(|i| config.profiles.get(i)),
).len();
if let Some(sel) = table_state.selected() {
if sel >= new_len && new_len > 0 {
table_state.select(Some(new_len - 1));
} else if new_len == 0 {
table_state.select(None);
}
}
}
KeyCode::Char(c) => {
enter_char(&mut search_query, &mut cursor_pos, c);
let new_len = compute_displayed_indices(
&rows_data, &repo_full_names, &pr_providers, &is_draft, show_drafts, &search_query,
active_profile_idx.and_then(|i| config.profiles.get(i)),
).len();
if let Some(sel) = table_state.selected() {
if sel >= new_len && new_len > 0 {
table_state.select(Some(new_len - 1));
} else if new_len == 0 {
table_state.select(None);
}
}
}
_ => {}
},
AppMode::ShowingDetail => match key.code {
KeyCode::Char(' ') | KeyCode::Esc => {
mode = AppMode::Normal;
}
KeyCode::Char('J') => {
if let Some(ref base_url) = config.jira.base_url {
if let Some(display_i) = table_state.selected() {
if let Some(&raw_i) = displayed_snapshot.get(display_i) {
if let Some(pr) = prs.as_ref().and_then(|v| v.get(raw_i)) {
if let Some(key_str) = extract_jira_key(&pr.title) {
let jira_url = format!("{}/browse/{}", base_url.trim_end_matches('/'), key_str);
let _ = open::that(jira_url);
}
}
}
}
}
}
_ => {}
},
}
}
}
}
Ok(())
}
fn format_review_status(status: &ReviewStatus) -> &'static str {
match status {
ReviewStatus::NeedsReview => "review",
ReviewStatus::Approved => "approved",
ReviewStatus::ChangesRequested => "changes",
ReviewStatus::Mixed => "mixed",
ReviewStatus::InReview => "in review",
}
}
fn format_ci_status(ci: &Option<CiStatus>) -> &'static str {
match ci {
None => "\u{F068}", Some(CiStatus::Success) => "\u{F058}", Some(CiStatus::Failed) => "\u{F057}", Some(CiStatus::Pending) | Some(CiStatus::Running) => "\u{F110}", Some(CiStatus::Cancelled) => "\u{F068}", }
}
fn format_age(dt: &chrono::DateTime<chrono::Utc>) -> String {
let elapsed = chrono::Utc::now().signed_duration_since(*dt);
let secs = elapsed.num_seconds();
if secs < 0 {
return "future".to_string();
}
if secs < 60 {
return "now".to_string();
}
let mins = elapsed.num_minutes();
if mins < 60 {
return format!("{}m", mins);
}
let hours = elapsed.num_hours();
if hours < 24 {
return format!("{}h", hours);
}
let days = elapsed.num_days();
if days < 7 {
return format!("{}d", days);
}
let weeks = days / 7;
if weeks < 8 {
return format!("{}w", weeks);
}
let months = days / 30;
if months < 12 {
return format!("{}mo", months);
}
format!("{}y", days / 365)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{PrIdentifier, PrState, User};
use chrono::{Duration, Utc};
#[test]
fn enter_char_unicode() {
let mut s = "héllo".to_string();
let mut cursor: usize = 5;
enter_char(&mut s, &mut cursor, '!');
assert_eq!(s, "héllo!");
assert_eq!(cursor, 6);
let mut s2 = String::new();
let mut cursor2: usize = 0;
enter_char(&mut s2, &mut cursor2, 'a');
assert_eq!(s2, "a");
assert_eq!(cursor2, 1);
}
#[test]
fn delete_char_removes_last() {
let mut s = "abc".to_string();
let mut cursor: usize = 3;
delete_char(&mut s, &mut cursor);
assert_eq!(s, "ab");
assert_eq!(cursor, 2);
let mut s2 = "abc".to_string();
let mut cursor2: usize = 0;
delete_char(&mut s2, &mut cursor2);
assert_eq!(s2, "abc");
assert_eq!(cursor2, 0);
}
#[test]
fn popup_area_contained() {
let area = Rect { x: 0, y: 0, width: 80, height: 24 };
let result = popup_area(area, 70, 80);
assert!(result.x >= 0, "x should be >= 0");
assert!(result.x + result.width <= 80, "x+width should fit in 80");
assert!(result.y >= 0, "y should be >= 0");
assert!(result.y + result.height <= 24, "y+height should fit in 24");
assert!(result.width < 80, "width should be less than parent");
assert!(result.height < 24, "height should be less than parent");
}
#[test]
fn search_filter() {
let rows_data: Vec<[String; 9]> = vec![
["".to_string(), "1".to_string(), "fix PROJ-123".to_string(), "bob".to_string(), "repo".to_string(), "".to_string(), "".to_string(), "0".to_string(), "1h".to_string()],
["".to_string(), "2".to_string(), "chore: cleanup".to_string(), "carol".to_string(), "repo".to_string(), "".to_string(), "".to_string(), "0".to_string(), "1h".to_string()],
["".to_string(), "3".to_string(), "feat: alice feature".to_string(), "dave".to_string(), "repo".to_string(), "".to_string(), "".to_string(), "0".to_string(), "1h".to_string()],
];
let is_draft = vec![false, false, false];
let indices = compute_displayed_indices(&rows_data, &[], &[], &is_draft, false, "alice", None);
assert_eq!(indices, vec![2]);
let indices_all = compute_displayed_indices(&rows_data, &[], &[], &is_draft, false, "", None);
assert_eq!(indices_all, vec![0, 1, 2]);
}
#[test]
fn draft_filter_hides_drafts() {
let rows_data: Vec<[String; 9]> = vec![
["".to_string(), "1".to_string(), "PR one".to_string(), "a".to_string(), "r".to_string(), "".to_string(), "".to_string(), "0".to_string(), "1h".to_string()],
["".to_string(), "2".to_string(), "PR two draft".to_string(), "b".to_string(), "r".to_string(), "".to_string(), "".to_string(), "0".to_string(), "1h".to_string()],
["".to_string(), "3".to_string(), "PR three".to_string(), "c".to_string(), "r".to_string(), "".to_string(), "".to_string(), "0".to_string(), "1h".to_string()],
];
let is_draft = vec![false, true, false];
let indices = compute_displayed_indices(&rows_data, &[], &[], &is_draft, false, "", None);
assert_eq!(indices, vec![0, 2]);
}
#[test]
fn draft_filter_shows_drafts() {
let rows_data: Vec<[String; 9]> = vec![
["".to_string(), "1".to_string(), "PR one".to_string(), "a".to_string(), "r".to_string(), "".to_string(), "".to_string(), "0".to_string(), "1h".to_string()],
["".to_string(), "2".to_string(), "PR two draft".to_string(), "b".to_string(), "r".to_string(), "".to_string(), "".to_string(), "0".to_string(), "1h".to_string()],
["".to_string(), "3".to_string(), "PR three".to_string(), "c".to_string(), "r".to_string(), "".to_string(), "".to_string(), "0".to_string(), "1h".to_string()],
];
let is_draft = vec![false, true, false];
let indices = compute_displayed_indices(&rows_data, &[], &[], &is_draft, true, "", None);
assert_eq!(indices, vec![0, 1, 2]);
}
#[test]
fn profile_filter_by_repo() {
let rows_data: Vec<[String; 9]> = vec![
["".to_string(), "1".to_string(), "PR one".to_string(), "a".to_string(), "my-repo".to_string(), "review".to_string(), "".to_string(), "0".to_string(), "1h".to_string()],
["".to_string(), "2".to_string(), "PR two".to_string(), "b".to_string(), "other-repo".to_string(), "review".to_string(), "".to_string(), "0".to_string(), "1h".to_string()],
];
let is_draft = vec![false, false];
let profile = crate::config::ProfileConfig {
name: "work".to_string(),
filters: vec![crate::config::FilterGroup { repo: Some("my-repo".to_string()), ..Default::default() }],
};
let full_names = vec!["org/my-repo".to_string(), "org/other-repo".to_string()];
let indices = compute_displayed_indices(&rows_data, &full_names, &[], &is_draft, false, "", Some(&profile));
assert_eq!(indices, vec![0]);
}
#[test]
fn profile_filter_by_org() {
let rows_data: Vec<[String; 9]> = vec![
["".to_string(), "1".to_string(), "PR one".to_string(), "a".to_string(), "acme-service".to_string(), "review".to_string(), "".to_string(), "0".to_string(), "1h".to_string()],
["".to_string(), "2".to_string(), "PR two".to_string(), "b".to_string(), "other-service".to_string(), "review".to_string(), "".to_string(), "0".to_string(), "1h".to_string()],
];
let is_draft = vec![false, false];
let profile = crate::config::ProfileConfig {
name: "acme".to_string(),
filters: vec![crate::config::FilterGroup { org: Some("acme".to_string()), ..Default::default() }],
};
let full_names = vec!["acme/acme-service".to_string(), "other/other-service".to_string()];
let indices = compute_displayed_indices(&rows_data, &full_names, &[], &is_draft, false, "", Some(&profile));
assert_eq!(indices, vec![0]);
}
#[test]
fn profile_filter_org_ignores_repo_name() {
let rows_data: Vec<[String; 9]> = vec![
["".to_string(), "1".to_string(), "Private PR".to_string(), "a".to_string(), "dynatrace-app".to_string(), "review".to_string(), "".to_string(), "0".to_string(), "1h".to_string()],
["".to_string(), "2".to_string(), "DT PR".to_string(), "b".to_string(), "some-service".to_string(), "review".to_string(), "".to_string(), "0".to_string(), "1h".to_string()],
];
let is_draft = vec![false, false];
let profile = crate::config::ProfileConfig {
name: "work".to_string(),
filters: vec![crate::config::FilterGroup { org: Some("dynatrace".to_string()), ..Default::default() }],
};
let full_names = vec![
"christoph.wedenig/dynatrace-app".to_string(), "Dynatrace-apps/some-service".to_string(), ];
let indices = compute_displayed_indices(&rows_data, &full_names, &[], &is_draft, false, "", Some(&profile));
assert_eq!(indices, vec![1], "private repo whose repo-name contains filter keyword must not match");
}
#[test]
fn profile_filter_none_passes_all() {
let rows_data: Vec<[String; 9]> = vec![
["".to_string(), "1".to_string(), "PR one".to_string(), "a".to_string(), "repo-a".to_string(), "review".to_string(), "".to_string(), "0".to_string(), "1h".to_string()],
["".to_string(), "2".to_string(), "PR two".to_string(), "b".to_string(), "repo-b".to_string(), "review".to_string(), "".to_string(), "0".to_string(), "1h".to_string()],
];
let is_draft = vec![false, false];
let indices = compute_displayed_indices(&rows_data, &[], &[], &is_draft, false, "", None);
assert_eq!(indices, vec![0, 1]);
}
#[test]
fn jira_key_extraction() {
assert_eq!(extract_jira_key("fix PROJ-123: description"), Some("PROJ-123".to_string()));
}
#[test]
fn jira_key_extraction_none() {
assert_eq!(extract_jira_key("no ticket here"), None);
}
#[test]
fn jira_key_extraction_first() {
assert_eq!(extract_jira_key("AB-1 and CD-2"), Some("AB-1".to_string()));
}
fn make_pr(provider: &str, number: u64, title: &str, url: &str) -> PullRequest {
PullRequest {
id: PrIdentifier {
provider: provider.to_string(),
owner: "acme".to_string(),
repo: "widget".to_string(),
number,
},
number,
title: title.to_string(),
url: url.to_string(),
author: User {
login: "alice".to_string(),
display_name: None,
avatar_url: None,
},
reviewers: vec![],
repo_full_name: "acme/widget".to_string(),
provider: provider.to_string(),
head_branch: "feat/test".to_string(),
base_branch: "main".to_string(),
state: PrState::Open,
review_status: ReviewStatus::NeedsReview,
ci_status: None,
draft: false,
created_at: Utc::now(),
updated_at: Utc::now() - Duration::hours(1),
labels: vec![],
comment_count: 0,
additions: None,
deletions: None,
}
}
#[test]
fn run_interactive_empty_does_not_panic() {
let (tx, rx) = std::sync::mpsc::sync_channel(1);
tx.send((vec![], vec![])).unwrap();
let result = run_interactive(rx, &crate::config::Config::default(), None);
assert!(result.is_ok());
}
#[test]
fn format_age_minutes() {
let dt = Utc::now() - Duration::minutes(45);
assert_eq!(format_age(&dt), "45m");
}
#[test]
fn format_age_hours() {
let dt = Utc::now() - Duration::hours(3);
assert_eq!(format_age(&dt), "3h");
}
#[test]
fn format_review_status_all_variants() {
assert_eq!(format_review_status(&ReviewStatus::NeedsReview), "review");
assert_eq!(format_review_status(&ReviewStatus::Approved), "approved");
assert_eq!(format_review_status(&ReviewStatus::ChangesRequested), "changes");
assert_eq!(format_review_status(&ReviewStatus::Mixed), "mixed");
assert_eq!(format_review_status(&ReviewStatus::InReview), "in review");
}
#[test]
fn format_ci_status_icons() {
assert_eq!(format_ci_status(&None), "\u{F068}");
assert_eq!(format_ci_status(&Some(CiStatus::Success)), "\u{F058}");
assert_eq!(format_ci_status(&Some(CiStatus::Failed)), "\u{F057}");
assert_eq!(format_ci_status(&Some(CiStatus::Pending)), "\u{F110}");
assert_eq!(format_ci_status(&Some(CiStatus::Cancelled)), "\u{F068}");
}
#[test]
fn provider_badge_github() {
let pr = make_pr("github", 1, "Test", "https://github.com/acme/widget/pull/1");
let src = match pr.provider.as_str() {
"github" => "\u{E709}",
"bitbucket" => "\u{E703}",
other => other,
};
assert_eq!(src, "\u{E709}");
}
#[test]
fn provider_badge_bitbucket() {
let pr = make_pr("bitbucket", 1, "Test", "https://bitbucket.org/acme/widget/pull-requests/1");
let src = match pr.provider.as_str() {
"github" => "\u{E709}",
"bitbucket" => "\u{E703}",
other => other,
};
assert_eq!(src, "\u{E703}");
}
}