use std::collections::HashSet;
use std::time::{Duration, Instant};
use super::IssuesPanelOverlay;
use crate::store::issues::{IssueFilter, Priority, Status};
pub const REFRESH_INTERVAL: Duration = Duration::from_secs(5);
pub(crate) const PAGE_STEP: usize = 10;
pub(super) const DOUBLE_CLICK_MS: u64 = 500;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum View {
List,
Detail,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatusFilter {
Open,
All,
}
#[derive(Clone, Default)]
pub struct UiTx(pub Option<tokio::sync::mpsc::UnboundedSender<crate::tui::app::UiEvent>>);
impl IssuesPanelOverlay {
pub(super) fn refresh(&mut self) {
let status = match self.status_filter {
StatusFilter::Open => Some(Status::Open),
StatusFilter::All => None,
};
let filter = IssueFilter {
status,
priority: self.custom_filter.as_ref().and_then(|f| f.priority),
label: self.custom_filter.as_ref().and_then(|f| f.label.clone()),
assigned_to_session: None,
text: self.custom_filter.as_ref().and_then(|f| f.text.clone()),
};
self.items = self.store.list(&filter).unwrap_or_default();
let live_ids: HashSet<u32> = self.items.iter().map(|i| i.meta.id).collect();
self.hash_cache.retain(|id, _| live_ids.contains(id));
let len = self.items.len();
let cur = self.list_state.selected();
if len == 0 {
self.list_state.select(None);
} else if cur.is_none_or(|s| s >= len) {
self.list_state.select(Some(0));
}
self.clamp_detail_scroll();
self.action_in_flight = false;
self.last_refresh = Instant::now();
}
pub(super) fn parse_filter_input(input: &str) -> Option<crate::store::issues::IssueFilter> {
let mut filter = crate::store::issues::IssueFilter::default();
let mut any = false;
for tok in input.split_whitespace() {
let Some((k, v)) = tok.split_once('=') else {
continue;
};
match k.trim() {
"priority" => {
if let Some(p) = Self::parse_priority_loose(v.trim()) {
filter.priority = Some(p);
any = true;
}
}
"label" => {
let l = v.trim();
if !l.is_empty() {
filter.label = Some(l.to_string());
any = true;
}
}
"text" => {
let t = v.trim();
if !t.is_empty() {
filter.text = Some(t.to_string());
any = true;
}
}
"status" => match v.trim().to_lowercase().as_str() {
"open" => {
filter.status = Some(Status::Open);
any = true;
}
"closed" => {
filter.status = Some(Status::Closed);
any = true;
}
"all" => {
filter.status = None;
any = true;
}
_ => {}
},
_ => {}
}
}
if any { Some(filter) } else { None }
}
fn parse_priority_loose(s: &str) -> Option<Priority> {
match s.to_lowercase().as_str() {
"low" | "l" => Some(Priority::Low),
"medium" | "med" | "m" | "default" => Some(Priority::Medium),
"high" | "h" => Some(Priority::High),
"critical" | "crit" | "c" | "urgent" => Some(Priority::Critical),
_ => None,
}
}
pub(super) fn current_hash(&mut self) -> String {
let Some(issue) = self.selected().cloned() else {
return String::new();
};
if let Some(h) = self.hash_cache.get(&issue.meta.id) {
return h.clone();
}
let hash = self
.store
.read(issue.meta.id)
.map(|(_, h)| h)
.unwrap_or_else(|_| String::from("<hash-unavailable>"));
self.hash_cache.insert(issue.meta.id, hash.clone());
hash
}
pub(super) fn selected(&self) -> Option<&crate::store::issues::Issue> {
self.list_state.selected().and_then(|i| self.items.get(i))
}
pub(super) fn selected_position_label(&self) -> String {
match (self.list_state.selected(), self.items.len()) {
(Some(i), n) if n > 0 => format!("{} of {}", i + 1, n),
_ => format!("0 of {}", self.items.len()),
}
}
pub(super) fn filter_label(&self) -> &'static str {
match self.status_filter {
StatusFilter::Open => "open",
StatusFilter::All => "all",
}
}
pub(super) fn move_selection(&mut self, delta: isize) {
if self.items.is_empty() {
return;
}
let cur = self.list_state.selected().unwrap_or(0);
let len = self.items.len() as isize;
let next = (cur as isize + delta).rem_euclid(len) as usize;
self.list_state.select(Some(next));
self.detail_scroll = 0;
}
pub(super) fn jump_first(&mut self) {
if !self.items.is_empty() {
self.list_state.select(Some(0));
self.detail_scroll = 0;
}
}
pub(super) fn jump_last(&mut self) {
if !self.items.is_empty() {
self.list_state.select(Some(self.items.len() - 1));
self.detail_scroll = 0;
}
}
pub(super) fn page_selection(&mut self, delta_pages: isize) {
let step = (PAGE_STEP as isize).max(1);
self.move_selection(delta_pages * step);
}
pub(super) fn clamp_detail_scroll(&mut self) {
let total = if self.total_wrapped_rows > 0 {
self.total_wrapped_rows
} else {
self.detail_body_lines()
};
let visible = self.detail_visible.max(1);
let max = total.saturating_sub(visible);
if self.detail_scroll > max {
self.detail_scroll = max;
}
}
pub(super) fn detail_body_lines(&self) -> usize {
self.selected().map(|i| i.body.lines().count()).unwrap_or(0)
}
pub(super) fn list_hit_test(&self, row: u16) -> Option<usize> {
if self.last_inner.width == 0 || self.last_inner.height == 0 {
return None;
}
let inner_top = self.last_inner.y;
let inner_bottom = self.last_inner.y + self.last_inner.height;
if row < inner_top || row >= inner_bottom {
return None;
}
let offset = (row - inner_top) as usize;
let base = self.list_state.offset();
let idx = base + offset;
if idx < self.items.len() {
Some(idx)
} else {
None
}
}
pub(super) fn is_double_click(&mut self, idx: usize) -> bool {
let Some(prev_idx) = self.last_click_idx else {
return false;
};
let Some(prev_at) = self.last_click_at else {
return false;
};
let within_window = prev_at.elapsed().as_millis() <= DOUBLE_CLICK_MS as u128;
let same_row = prev_idx == idx;
if within_window && same_row {
self.last_click_idx = None;
self.last_click_at = None;
true
} else {
false
}
}
pub(super) fn record_click(&mut self, idx: usize) {
self.last_click_idx = Some(idx);
self.last_click_at = Some(Instant::now());
}
pub(super) fn dispatch_action<F, Fut>(&mut self, _label: &'static str, f: F)
where
F: FnOnce(
crate::store::issues::FileIssueStore,
u32,
tokio::sync::mpsc::UnboundedSender<crate::tui::app::UiEvent>,
) -> Fut
+ Send
+ 'static,
Fut: std::future::Future<Output = ()> + Send + 'static,
{
if self.action_in_flight {
return;
}
let Some(issue) = self.selected().cloned() else {
return;
};
let Some(tx) = self.ui_tx.0.clone() else {
return;
};
let store = (*self.store).clone();
let id = issue.meta.id;
self.action_in_flight = true;
tokio::spawn(f(store, id, tx));
}
}