mod input;
mod render;
mod state;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;
use ratatui::{layout::Rect, widgets::ListState};
use crate::store::issues::{FileIssueStore, Issue};
pub(super) use state::{REFRESH_INTERVAL, StatusFilter, UiTx, View};
#[allow(dead_code)]
const MAX_BODY_RENDER_LINES: usize = 2000;
pub struct IssuesPanelOverlay {
pub(super) store: Arc<FileIssueStore>,
pub(super) view: View,
pub(super) items: Vec<Issue>,
pub(super) list_state: ListState,
pub(super) detail_scroll: usize,
pub(super) detail_visible: usize,
pub(super) total_wrapped_rows: usize,
pub(super) status_filter: StatusFilter,
pub(super) custom_filter: Option<crate::store::issues::IssueFilter>,
pub(super) last_refresh: Instant,
pub(super) ui_tx: UiTx,
pub(super) hash_cache: HashMap<u32, String>,
pub(super) action_in_flight: bool,
pub(super) last_inner: Rect,
pub(super) last_click_idx: Option<usize>,
pub(super) last_click_at: Option<Instant>,
pub(super) filter_input_mode: bool,
pub(super) filter_input_text: String,
}
impl std::fmt::Debug for IssuesPanelOverlay {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("IssuesPanelOverlay")
.field("view", &self.view)
.field("items", &self.items.len())
.field("status_filter", &self.status_filter)
.field("detail_scroll", &self.detail_scroll)
.field(
"last_refresh_secs_ago",
&self.last_refresh.elapsed().as_secs(),
)
.finish()
}
}
impl IssuesPanelOverlay {
pub fn new(store: FileIssueStore) -> Self {
let s = Arc::new(store);
let mut me = Self {
store: s,
view: View::List,
items: Vec::new(),
list_state: ListState::default(),
detail_scroll: 0,
detail_visible: render::DETAIL_BODY_LINES_HINT,
total_wrapped_rows: 0,
status_filter: StatusFilter::Open,
custom_filter: None,
last_refresh: Instant::now() - REFRESH_INTERVAL, ui_tx: UiTx::default(),
hash_cache: HashMap::new(),
action_in_flight: false,
last_inner: Rect::default(),
last_click_idx: None,
last_click_at: None,
filter_input_mode: false,
filter_input_text: String::new(),
};
me.refresh();
me
}
pub fn set_ui_tx(&mut self, tx: tokio::sync::mpsc::UnboundedSender<crate::tui::app::UiEvent>) {
self.ui_tx = UiTx(Some(tx));
}
pub fn pre_select(&mut self, id: u32) -> bool {
self.refresh();
if let Some(pos) = self.items.iter().position(|i| i.meta.id == id) {
self.list_state.select(Some(pos));
self.detail_scroll = 0;
self.view = View::Detail;
true
} else {
false
}
}
pub fn tick(&mut self) {
if self.last_refresh.elapsed() >= REFRESH_INTERVAL {
self.refresh();
}
}
pub fn session_id() -> &'static str {
crate::store::issues::liveness::TUI_OWNERSHIP_ID
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::issues::{FileIssueStore, Priority, Status};
use crate::tui::overlay::OverlayComponent;
fn tmp_store() -> (tempfile::TempDir, FileIssueStore) {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join(".oxi").join("issues");
std::fs::create_dir_all(&dir).unwrap();
let store = FileIssueStore::open(dir).unwrap();
(tmp, store)
}
#[tokio::test]
async fn pre_select_finds_existing_id_and_switches_to_detail() {
let (_tmp, store) = tmp_store();
store
.create("First".into(), "".into(), Priority::Medium, vec![], None)
.unwrap();
store
.create("Second".into(), "".into(), Priority::High, vec![], None)
.unwrap();
let mut panel = IssuesPanelOverlay::new(store);
let initial = panel.list_state.selected();
assert!(initial.is_some());
assert!(panel.pre_select(2));
assert_eq!(panel.view, View::Detail);
let sel = panel.list_state.selected().unwrap();
assert_eq!(panel.items[sel].meta.id, 2);
}
#[tokio::test]
async fn pre_select_returns_false_for_missing_id() {
let (_tmp, store) = tmp_store();
store
.create("Only".into(), "".into(), Priority::Low, vec![], None)
.unwrap();
let mut panel = IssuesPanelOverlay::new(store);
let ok = panel.pre_select(99);
assert!(!ok, "pre_select should return false for unknown id");
assert_eq!(panel.view, View::List);
}
#[test]
fn session_id_is_tui_for_ownership() {
assert_eq!(IssuesPanelOverlay::session_id(), "tui");
}
#[tokio::test]
async fn refresh_recomputes_cache_after_create() {
let (_tmp, store) = tmp_store();
store
.create("A".into(), "".into(), Priority::Low, vec![], None)
.unwrap();
let panel = IssuesPanelOverlay::new(store.clone());
assert_eq!(panel.items.len(), 1);
store
.create("B".into(), "".into(), Priority::High, vec![], None)
.unwrap();
let mut panel2 = IssuesPanelOverlay::new(store.clone());
assert_eq!(panel2.items.len(), 2);
panel2.refresh();
assert_eq!(panel2.items.len(), 2);
}
#[tokio::test]
async fn hint_strings_mention_new_keys() {
let (_tmp, store) = tmp_store();
let mut panel = IssuesPanelOverlay::new(store);
let list_hint = panel.hint().to_string();
assert!(
list_hint.contains("s/r/c:act"),
"list hint missing: {list_hint}"
);
assert!(list_hint.contains("Esc:back") || list_hint.contains("q:close"));
assert!(list_hint.contains("/:filter"), "list hint missing /:filter");
panel.view = View::Detail;
let detail_hint = panel.hint().to_string();
assert!(
detail_hint.contains("Esc:back"),
"detail hint missing: {detail_hint}"
);
assert!(
detail_hint.contains("J/K:scroll"),
"detail hint missing scroll: {detail_hint}"
);
}
#[tokio::test]
async fn page_navigation_wraps_and_clamps() {
let (_tmp, store) = tmp_store();
for i in 0..15 {
store
.create(
format!("Issue {i}"),
"".into(),
Priority::Medium,
vec![],
None,
)
.unwrap();
}
let mut panel = IssuesPanelOverlay::new(store);
assert_eq!(panel.items.len(), 15);
panel.jump_first();
assert_eq!(panel.list_state.selected(), Some(0));
panel.page_selection(1);
assert_eq!(panel.list_state.selected(), Some(10));
panel.page_selection(-1);
assert_eq!(panel.list_state.selected(), Some(0));
panel.jump_last();
assert_eq!(panel.list_state.selected(), Some(14));
}
#[tokio::test]
async fn detail_scroll_clamps_against_body_length() {
let (_tmp, store) = tmp_store();
let body = (0..50)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
store
.create("Scrollable".into(), body, Priority::High, vec![], None)
.unwrap();
let mut panel = IssuesPanelOverlay::new(store);
panel.detail_visible = 5;
assert_eq!(panel.detail_scroll, 0);
panel.detail_scroll = 999;
panel.clamp_detail_scroll();
assert_eq!(panel.detail_scroll, 45);
}
#[tokio::test]
async fn status_filter_toggles_between_open_and_all() {
let (_tmp, store) = tmp_store();
store
.create("Open one".into(), "".into(), Priority::Low, vec![], None)
.unwrap();
store
.create("Will close".into(), "".into(), Priority::Low, vec![], None)
.unwrap();
let issues_dir = store.issues_dir();
let _guard = crate::store::issues::liveness::acquire(&issues_dir, "tui").unwrap();
let (_, h) = store.read(2).unwrap();
store.start(2, "tui", Some(h)).await.unwrap();
let (_, h) = store.read(2).unwrap();
store.close(2, "tui", Some(h)).await.unwrap();
drop(_guard);
let mut panel = IssuesPanelOverlay::new(store);
assert_eq!(panel.items.len(), 1);
panel.status_filter = StatusFilter::All;
panel.refresh();
assert_eq!(panel.items.len(), 2);
}
use oxi_tui::Theme;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn render_to_text(panel: &mut IssuesPanelOverlay, w: u16, h: u16) -> String {
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let area = frame.area();
panel.render(frame, area, &Theme::dark());
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
buffer
.content()
.iter()
.map(|c| c.symbol().chars().next().unwrap_or(' '))
.collect()
}
#[tokio::test]
async fn render_list_does_not_panic_on_small_terminal() {
let (_tmp, store) = tmp_store();
let mut panel = IssuesPanelOverlay::new(store);
for (w, h) in [(40, 4), (40, 5), (40, 8), (20, 4)] {
let _ = render_to_text(&mut panel, w, h);
}
}
#[tokio::test]
async fn render_detail_does_not_panic_on_small_terminal() {
let (_tmp, store) = tmp_store();
store
.create("Test".into(), "body".into(), Priority::Low, vec![], None)
.unwrap();
let mut panel = IssuesPanelOverlay::new(store);
panel.pre_select(1);
for (w, h) in [(40, 4), (40, 5), (40, 10), (20, 6)] {
let _ = render_to_text(&mut panel, w, h);
}
}
#[tokio::test]
async fn list_view_shows_count_in_title() {
let (_tmp, store) = tmp_store();
for i in 0..3 {
store
.create(format!("Issue {i}"), "".into(), Priority::Low, vec![], None)
.unwrap();
}
let mut panel = IssuesPanelOverlay::new(store);
let text = render_to_text(&mut panel, 80, 20);
assert!(text.contains("(3)"), "list title missing count");
assert!(text.contains("Issues"));
}
#[tokio::test]
async fn detail_view_shows_issue_metadata() {
let (_tmp, store) = tmp_store();
store
.create(
"Test detail".into(),
"first line\nsecond line".into(),
Priority::High,
vec!["bug".into()],
None,
)
.unwrap();
let mut panel = IssuesPanelOverlay::new(store);
panel.pre_select(1);
let text = render_to_text(&mut panel, 100, 24);
assert!(text.contains("status"));
assert!(text.contains("priority"));
assert!(text.contains("high"));
assert!(text.contains("bug"));
}
#[tokio::test]
async fn hash_cache_avoids_repeated_disk_reads() {
let (_tmp, store) = tmp_store();
store
.create(
"Hash test".into(),
"body".into(),
Priority::Low,
vec![],
None,
)
.unwrap();
let mut panel = IssuesPanelOverlay::new(store);
panel.pre_select(1);
let _ = render_to_text(&mut panel, 80, 20);
assert!(panel.hash_cache.contains_key(&1));
let cached = panel.hash_cache.get(&1).cloned().unwrap();
assert!(!cached.is_empty());
}
#[tokio::test]
async fn hash_cache_pruned_after_refresh() {
let (_tmp, store) = tmp_store();
store
.create("A".into(), "".into(), Priority::Low, vec![], None)
.unwrap();
store
.create("B".into(), "".into(), Priority::Low, vec![], None)
.unwrap();
let mut panel = IssuesPanelOverlay::new(store.clone());
let _ = render_to_text(&mut panel, 80, 20);
panel.pre_select(1);
let _ = render_to_text(&mut panel, 80, 20);
panel.pre_select(2);
let _ = render_to_text(&mut panel, 80, 20);
assert!(!panel.hash_cache.is_empty());
store.invalidate();
let path = store
.list(&crate::store::issues::IssueFilter::default())
.unwrap()
.first()
.map(|i| i.path.clone().unwrap());
if let Some(p) = path {
std::fs::remove_file(p).ok();
}
panel.refresh();
let live_ids: std::collections::HashSet<u32> =
panel.items.iter().map(|i| i.meta.id).collect();
for cached_id in panel.hash_cache.keys() {
assert!(
live_ids.contains(cached_id),
"stale hash entry for {cached_id}"
);
}
}
#[test]
fn double_click_detection_works() {
let (_tmp, store) = tmp_store();
let mut panel = IssuesPanelOverlay::new(store);
assert!(!panel.is_double_click(3));
panel.record_click(3);
assert!(panel.is_double_click(3));
assert!(!panel.is_double_click(3));
panel.record_click(3);
assert!(!panel.is_double_click(5));
}
#[test]
fn detail_body_renders_markdown_headers() {
let (_tmp, store) = tmp_store();
store
.create(
"Doc".into(),
"# Heading\n\nlist items: alpha and beta\n\n`code` and **bold**".into(),
Priority::Low,
vec![],
None,
)
.unwrap();
let mut panel = IssuesPanelOverlay::new(store);
panel.pre_select(1);
let text = render_to_text(&mut panel, 100, 30);
assert!(text.contains("Heading"), "heading text missing: {text}");
assert!(text.contains("alpha"));
assert!(text.contains("beta"));
}
#[test]
fn detail_body_handles_code_fence() {
let (_tmp, store) = tmp_store();
store
.create(
"Code".into(),
"```rust\nfn main() {}\n```\n".into(),
Priority::Low,
vec![],
None,
)
.unwrap();
let mut panel = IssuesPanelOverlay::new(store);
panel.pre_select(1);
let _text = render_to_text(&mut panel, 100, 30);
}
#[test]
fn filter_input_parses_priority_label_and_text() {
let f = IssuesPanelOverlay::parse_filter_input("priority=critical label=auth text=login")
.expect("expected filter");
assert_eq!(f.priority, Some(crate::store::issues::Priority::Critical));
assert_eq!(f.label.as_deref(), Some("auth"));
assert_eq!(f.text.as_deref(), Some("login"));
assert!(f.status.is_none());
}
#[test]
fn filter_input_empty_returns_none() {
assert!(IssuesPanelOverlay::parse_filter_input("").is_none());
assert!(IssuesPanelOverlay::parse_filter_input(" ").is_none());
}
#[test]
fn filter_input_status_overrides_status_field() {
let f = IssuesPanelOverlay::parse_filter_input("status=closed").unwrap();
assert_eq!(f.status, Some(Status::Closed));
}
#[test]
fn filter_input_ignores_unknown_tokens() {
let f = IssuesPanelOverlay::parse_filter_input("priority=low bogus=value").unwrap();
assert_eq!(f.priority, Some(crate::store::issues::Priority::Low));
}
#[tokio::test]
async fn slash_enters_filter_input_mode() {
let (_tmp, store) = tmp_store();
let mut panel = IssuesPanelOverlay::new(store);
assert!(!panel.filter_input_mode);
panel.handle_key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('/'),
crossterm::event::KeyModifiers::NONE,
));
assert!(panel.filter_input_mode);
}
#[test]
fn filter_input_enter_applies_filter_and_exits_mode() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let (_tmp, store) = tmp_store();
let mut panel = IssuesPanelOverlay::new(store);
panel.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE));
for ch in "priority=high".chars() {
panel.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
assert!(panel.filter_input_mode);
assert_eq!(panel.filter_input_text, "priority=high");
panel.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(!panel.filter_input_mode);
assert!(panel.custom_filter.is_some());
assert_eq!(
panel.custom_filter.as_ref().unwrap().priority,
Some(crate::store::issues::Priority::High)
);
}
#[test]
fn filter_input_esc_cancels_without_applying() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let (_tmp, store) = tmp_store();
let mut panel = IssuesPanelOverlay::new(store);
panel.handle_key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE));
for ch in "priority=high".chars() {
panel.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
panel.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(!panel.filter_input_mode);
assert!(panel.custom_filter.is_none());
assert_eq!(panel.filter_input_text, "");
}
}