use std::time::{Duration, Instant};
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::Terminal;
use tokio::sync::mpsc;
use crate::monitor::dashboard::format_count;
use crate::monitor::search_client::{ReindexEvent, SearchClient, resolve_search_url};
use crate::monitor::tui_common::{enter_tui, leave_tui};
use crate::monitor::utils::DaemonStatus;
use super::nav::{navigate_down_visible, navigate_up_visible, new_log_lines_since};
use super::render::{SearchFocus, render};
use super::state::SearchTuiState;
const REFRESH_INTERVAL: Duration = Duration::from_millis(2000);
const INPUT_POLL: Duration = Duration::from_millis(50);
const SEARCH_TOP_K: usize = 5;
#[derive(Debug, Clone)]
pub struct ScopedReindexEvent {
pub index_id: String,
pub event: ReindexEvent,
}
pub fn apply_reindex_event(state: &mut SearchTuiState, scoped: ScopedReindexEvent) {
let id = scoped.index_id;
match scoped.event {
ReindexEvent::Started { total_files } => {
state
.log
.push_scoped(&id, format!("reindex started: {total_files} files"));
}
ReindexEvent::Progress {
indexed,
total_files,
} => {
let pct = indexed
.saturating_mul(100)
.checked_div(total_files)
.unwrap_or(0);
state
.log
.push_scoped(&id, format!("indexing: {indexed}/{total_files} ({pct}%)"));
}
ReindexEvent::Complete {
total_chunks,
status,
} => {
state.log.push_scoped(
&id,
format!("reindex {status}: {} chunks", format_count(total_chunks)),
);
}
ReindexEvent::Failed(message) => {
state
.log
.push_scoped(&id, format!("reindex error: {message}"));
}
}
}
pub async fn run() -> anyhow::Result<()> {
run_with_url(resolve_search_url()).await
}
pub async fn run_with_url(base_url: String) -> anyhow::Result<()> {
let mut client = SearchClient::new(base_url.clone());
let mut state = SearchTuiState::new(base_url);
let mut terminal = enter_tui()?;
let result = run_loop(&mut terminal, &mut state, &mut client).await;
leave_tui(&mut terminal)?;
result
}
async fn poll_daemon(state: &mut SearchTuiState, client: &mut SearchClient) {
if !state.daemon_status.is_online() {
let resolved = resolve_search_url();
if resolved != client.base_url() {
client.set_base_url(resolved.clone());
state.base_url = resolved;
}
}
match client.fetch_all().await {
Ok(data) => {
state.daemon_status = DaemonStatus::Online {
version: data.version,
uptime_secs: data.uptime_secs,
};
state.indexes = data.indexes;
state.clamp_selection();
}
Err(e) => {
state.daemon_status = DaemonStatus::Offline {
last_error: e.to_string(),
};
}
}
let tail = client.logs_tail(50).await;
if state.log_first_poll {
state.log_watermark = tail.last().cloned();
state.log_first_poll = false;
} else {
let new = new_log_lines_since(&tail, state.log_watermark.as_deref());
for line in new {
state.log.push(line.clone());
}
if let Some(last) = tail.last() {
state.log_watermark = Some(last.clone());
}
}
}
async fn run_search(state: &mut SearchTuiState, client: &SearchClient) {
let query = state.input.trim().to_string();
if query.is_empty() {
return;
}
if state.is_all_selected() {
run_search_all(state, client, &query).await;
} else if let Some(id) = state.selected_id().map(str::to_string) {
run_search_one(state, client, &id, &query).await;
} else {
state.log.push("search: no index selected");
}
state.input.clear();
}
async fn run_search_one(state: &mut SearchTuiState, client: &SearchClient, id: &str, query: &str) {
match client.search(id, query, SEARCH_TOP_K).await {
Ok(hits) => {
state
.log
.push_scoped(id, format!("search \"{query}\" → {} results", hits.len()));
for hit in &hits {
state
.log
.push_raw_scoped(id, format!(" {}:{} {}", hit.file, hit.line, hit.snippet));
}
}
Err(e) => state
.log
.push_scoped(id, format!("search \"{query}\" failed: {e}")),
}
}
async fn run_search_all(state: &mut SearchTuiState, client: &SearchClient, query: &str) {
let ids: Vec<String> = state.indexes.iter().map(|i| i.id.clone()).collect();
if ids.is_empty() {
state.log.push("search (all): no indexes registered");
return;
}
state
.log
.push(format!("search \"{query}\" (all) → {} indexes", ids.len()));
let mut total = 0usize;
for id in &ids {
match client.search(id, query, SEARCH_TOP_K).await {
Ok(hits) => {
total += hits.len();
state
.log
.push_raw_scoped(id, format!(" · {id}: {} results", hits.len()));
}
Err(e) => state
.log
.push_raw_scoped(id, format!(" · {id}: failed: {e}")),
}
}
state.log.push(format!(
"search \"{query}\" (all) → {total} results across {} indexes",
ids.len()
));
}
async fn run_loop<B: ratatui::backend::Backend>(
terminal: &mut Terminal<B>,
state: &mut SearchTuiState,
client: &mut SearchClient,
) -> anyhow::Result<()> {
poll_daemon(state, client).await;
let mut last_poll = Instant::now();
let (reindex_tx, mut reindex_rx) = mpsc::channel::<ScopedReindexEvent>(64);
loop {
terminal.draw(|f| render(f, state))?;
while let Ok(event) = reindex_rx.try_recv() {
apply_reindex_event(state, event);
}
let key = if event::poll(INPUT_POLL)? {
match event::read()? {
Event::Key(key) => Some(key),
_ => None,
}
} else {
None
};
if let Some(key) = key
&& key.kind != KeyEventKind::Release
{
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
return Ok(());
}
if state.show_help {
if matches!(key.code, KeyCode::Char('?') | KeyCode::Esc) {
state.show_help = false;
} else if key.code == KeyCode::Char('q') {
return Ok(());
}
continue;
}
match (state.focus, key.code) {
(SearchFocus::List, KeyCode::Esc) if state.filter_active => {
state.filter_active = false;
}
(SearchFocus::List, KeyCode::Enter) if state.filter_active => {
state.filter_active = false;
}
(SearchFocus::List, KeyCode::Backspace) if state.filter_active => {
state.filter.pop();
state.clamp_to_visible();
}
(SearchFocus::List, KeyCode::Char(c)) if state.filter_active => {
state.filter.push(c);
state.clamp_to_visible();
}
(SearchFocus::List, KeyCode::Tab) if state.filter_active => {}
(_, KeyCode::Char('?')) => state.show_help = true,
(_, KeyCode::Tab) => state.toggle_focus(),
(_, KeyCode::Esc) => return Ok(()),
(SearchFocus::List, KeyCode::Char('q')) => return Ok(()),
(SearchFocus::List, KeyCode::Up) => navigate_up_visible(state),
(SearchFocus::List, KeyCode::Down) => navigate_down_visible(state),
(SearchFocus::List, KeyCode::Char('/')) => {
state.filter_active = true;
state.filter.clear();
}
(SearchFocus::List, KeyCode::Char('s')) => {
state.sort_key = state.sort_key.next();
}
(SearchFocus::List, KeyCode::Char('g')) => {
state.group_by_project = !state.group_by_project;
}
(SearchFocus::List, KeyCode::Char('r')) => {
let targets: Vec<String> = if state.is_all_selected() {
state.indexes.iter().map(|i| i.id.clone()).collect()
} else {
state
.selected_id()
.map(str::to_string)
.into_iter()
.collect()
};
if targets.is_empty() {
if state.is_all_selected() {
state.log.push("reindex (all): no indexes registered");
} else {
state.log.push("reindex: no index selected");
}
} else {
if state.is_all_selected() {
state
.log
.push(format!("reindex triggered: all {} indexes", targets.len()));
}
for id in targets {
state
.log
.push_scoped(&id, format!("reindex triggered: {id}"));
spawn_reindex(client.clone(), reindex_tx.clone(), id);
}
}
}
(SearchFocus::Input, KeyCode::Enter) => {
run_search(state, client).await;
poll_daemon(state, client).await;
last_poll = Instant::now();
}
(SearchFocus::Input, KeyCode::Backspace) => {
state.input.pop();
}
(SearchFocus::Input, KeyCode::Char(c)) => state.input.push(c),
_ => {}
}
}
if last_poll.elapsed() >= REFRESH_INTERVAL {
poll_daemon(state, client).await;
last_poll = Instant::now();
}
}
}
fn spawn_reindex(client: SearchClient, out: mpsc::Sender<ScopedReindexEvent>, index_id: String) {
let (inner_tx, mut inner_rx) = mpsc::channel::<ReindexEvent>(64);
let stream_id = index_id.clone();
tokio::spawn(async move {
client.reindex_stream(&stream_id, inner_tx).await;
});
tokio::spawn(async move {
while let Some(event) = inner_rx.recv().await {
let scoped = ScopedReindexEvent {
index_id: index_id.clone(),
event,
};
if out.send(scoped).await.is_err() {
break;
}
}
});
}