use std::sync::mpsc;
use crossterm::event::{KeyCode, KeyEvent};
use crate::app::{App, ContainerLogsRequest, ContainerLogsSearch, Screen};
use crate::event::AppEvent;
pub const DEFAULT_TAIL: usize = 200;
pub(crate) fn tail_scroll(body_len: usize, viewport_h: u16) -> u16 {
body_len.saturating_sub(viewport_h as usize) as u16
}
pub(crate) fn center_scroll(line: usize, viewport_h: u16, body_len: usize) -> u16 {
let half = (viewport_h as usize) / 2;
let target = line.saturating_sub(half);
let max = body_len.saturating_sub(viewport_h as usize);
target.min(max) as u16
}
pub(crate) fn matches_line(haystack: &str, needle: &str) -> bool {
if needle.is_empty() {
return false;
}
if needle.chars().any(|c| c.is_uppercase()) {
haystack.contains(needle)
} else {
ascii_ci_find(haystack.as_bytes(), needle.as_bytes()).is_some()
}
}
pub(crate) fn match_indices_smart(haystack: &str, needle: &str) -> Vec<usize> {
if needle.is_empty() {
return Vec::new();
}
if needle.chars().any(|c| c.is_uppercase()) {
haystack.match_indices(needle).map(|(idx, _)| idx).collect()
} else {
ascii_ci_match_indices(haystack.as_bytes(), needle.as_bytes())
}
}
fn ascii_ci_find(hay: &[u8], needle: &[u8]) -> Option<usize> {
ascii_ci_match_indices(hay, needle).into_iter().next()
}
fn ascii_ci_match_indices(hay: &[u8], needle: &[u8]) -> Vec<usize> {
if needle.is_empty() || needle.len() > hay.len() {
return Vec::new();
}
let mut out = Vec::new();
let mut i = 0;
'outer: while i + needle.len() <= hay.len() {
for j in 0..needle.len() {
if !hay[i + j].eq_ignore_ascii_case(&needle[j]) {
i += 1;
continue 'outer;
}
}
out.push(i);
i += needle.len();
}
out
}
pub(crate) fn refresh_search(body: &[String], search: &mut ContainerLogsSearch) {
recompute_matches(body, search);
}
pub(crate) fn recenter_on_match(
body_len: usize,
last_render_height: u16,
search: &ContainerLogsSearch,
scroll: &mut u16,
) {
scroll_to_current_match(body_len, last_render_height, search, scroll);
}
fn recompute_matches(body: &[String], search: &mut ContainerLogsSearch) {
search.matches = body
.iter()
.enumerate()
.filter_map(|(idx, line)| {
if matches_line(line, &search.query) {
Some(idx)
} else {
None
}
})
.collect();
search.current = 0;
}
fn scroll_to_current_match(
body_len: usize,
last_render_height: u16,
search: &ContainerLogsSearch,
scroll: &mut u16,
) {
if let Some(line) = search.matches.get(search.current) {
*scroll = center_scroll(*line, last_render_height, body_len);
}
}
pub(super) fn handle_keys(app: &mut App, key: KeyEvent, _events_tx: &mpsc::Sender<AppEvent>) {
let Screen::ContainerLogs {
body,
scroll,
alias,
container_id,
container_name,
last_render_height,
search,
..
} = &mut app.screen
else {
return;
};
if let Some(s) = search.as_mut() {
match key.code {
KeyCode::Esc => {
log::debug!("[purple] container_logs: search closed");
*search = None;
}
KeyCode::Tab if !s.matches.is_empty() => {
s.current = (s.current + 1) % s.matches.len();
scroll_to_current_match(body.len(), *last_render_height, s, scroll);
}
KeyCode::BackTab if !s.matches.is_empty() => {
s.current = if s.current == 0 {
s.matches.len() - 1
} else {
s.current - 1
};
scroll_to_current_match(body.len(), *last_render_height, s, scroll);
}
KeyCode::Backspace => {
s.delete_char_before_cursor();
recompute_matches(body, s);
scroll_to_current_match(body.len(), *last_render_height, s, scroll);
}
KeyCode::Delete => {
s.delete_char_at_cursor();
recompute_matches(body, s);
scroll_to_current_match(body.len(), *last_render_height, s, scroll);
}
KeyCode::Left => s.move_left(),
KeyCode::Right => s.move_right(),
KeyCode::Home => s.move_home(),
KeyCode::End => s.move_end(),
KeyCode::Char(c) => {
s.insert_char(c);
recompute_matches(body, s);
scroll_to_current_match(body.len(), *last_render_height, s, scroll);
}
_ => {}
}
return;
}
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
log::debug!("[purple] container_logs: closed");
app.set_screen(Screen::HostList);
}
KeyCode::Char('?') => {
app.set_screen(Screen::Help {
return_screen: Box::new(Screen::HostList),
});
}
KeyCode::Char('/') => {
log::debug!("[purple] container_logs: search opened");
*search = Some(ContainerLogsSearch::default());
}
KeyCode::Char('j') | KeyCode::Down => {
*scroll = scroll.saturating_add(1);
}
KeyCode::Char('k') | KeyCode::Up => {
*scroll = scroll.saturating_sub(1);
}
KeyCode::PageDown => {
*scroll = scroll.saturating_add(20);
}
KeyCode::PageUp => {
*scroll = scroll.saturating_sub(20);
}
KeyCode::Char('g') => {
*scroll = 0;
}
KeyCode::Char('G') => {
*scroll = tail_scroll(body.len(), *last_render_height);
}
KeyCode::Char('r') => {
let alias = alias.clone();
let container_id = container_id.clone();
let container_name = container_name.clone();
requeue_logs_fetch(app, alias, container_id, container_name);
}
_ => {}
}
}
fn requeue_logs_fetch(app: &mut App, alias: String, container_id: String, container_name: String) {
let Some(entry) = app.container_cache.get(&alias) else {
log::debug!(
"[purple] container_logs: refresh aborted, no cache for alias={}",
alias
);
return;
};
let runtime = entry.runtime;
let askpass = app
.hosts_state
.list
.iter()
.find(|h| h.alias == alias)
.and_then(|h| h.askpass.clone());
if let Screen::ContainerLogs {
body,
scroll,
error,
fetched_at,
search,
..
} = &mut app.screen
{
body.clear();
*scroll = 0;
*error = None;
*fetched_at = 0;
if let Some(s) = search.as_mut() {
s.matches.clear();
s.current = 0;
}
}
log::debug!(
"[purple] container_logs: refresh queued alias={} id={}",
alias,
container_id
);
app.pending_container_logs = Some(ContainerLogsRequest {
alias,
askpass,
runtime,
container_id,
container_name,
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn smart_case_lowercase_query_matches_uppercase_haystack() {
assert!(matches_line("ERROR: Connection refused", "error"));
assert!(matches_line("Error", "error"));
}
#[test]
fn smart_case_uppercase_query_is_case_sensitive() {
assert!(matches_line("ERROR: Connection refused", "ERROR"));
assert!(!matches_line("error", "ERROR"));
assert!(!matches_line("Error", "ERROR"));
}
#[test]
fn empty_query_never_matches() {
assert!(!matches_line("anything", ""));
}
#[test]
fn match_indices_returns_every_hit() {
let positions = match_indices_smart("foo bar foo baz foo", "foo");
assert_eq!(positions, vec![0, 8, 16]);
}
#[test]
fn match_indices_smart_case_insensitive_is_ascii_safe() {
let positions = match_indices_smart("Foo FOO foo", "foo");
assert_eq!(positions, vec![0, 4, 8]);
}
#[test]
fn match_indices_non_overlapping() {
let positions = match_indices_smart("aaaaa", "aaa");
assert_eq!(positions, vec![0]);
}
#[test]
fn center_scroll_anchors_match_in_middle() {
assert_eq!(center_scroll(50, 20, 200), 40);
}
#[test]
fn center_scroll_clamps_to_zero_for_top_match() {
assert_eq!(center_scroll(2, 20, 200), 0);
}
#[test]
fn center_scroll_clamps_to_max_when_match_near_tail() {
assert_eq!(center_scroll(95, 20, 100), 80);
}
#[test]
fn recompute_matches_resets_current_to_first_hit() {
let body = vec![
"error 1".to_string(),
"ok".to_string(),
"error 2".to_string(),
];
let mut search = ContainerLogsSearch {
query: "error".to_string(),
current: 1,
..Default::default()
};
recompute_matches(&body, &mut search);
assert_eq!(search.matches, vec![0, 2]);
assert_eq!(search.current, 0, "current resets to first match");
}
#[test]
fn recompute_matches_resets_current_when_hits_shrink_to_none() {
let body = vec!["foo".to_string(), "bar".to_string()];
let mut search = ContainerLogsSearch {
query: "qux".to_string(),
current: 5,
..Default::default()
};
recompute_matches(&body, &mut search);
assert!(search.matches.is_empty());
assert_eq!(search.current, 0);
}
}