use ratatui::{
Frame,
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
};
use crate::monitor::dashboard::{IndexRow, format_count};
use crate::monitor::tui_common::{
self, ListFocus, ThreeWaySortKey, left_panel_width, panel_block, truncate,
};
use crate::monitor::utils::{DaemonStatus, fmt_uptime};
use super::nav::{filtered_sorted_indexes, visible_selected_row};
use super::state::SearchTuiState;
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
pub const KEY_HINT: &str = "[Tab] focus [r] reindex [↑↓] select [Enter] search [/] filter [s] sort [g] group [q] quit [?] help";
const SORT_LABELS: &[&str; 3] = &["Activity", "Name", "Chunks"];
pub type IndexSortKey = ThreeWaySortKey;
pub fn sort_label(key: ThreeWaySortKey) -> &'static str {
key.label(SORT_LABELS)
}
pub const ALL_LABEL: &str = "All indexes";
pub type SearchFocus = ListFocus;
pub fn help_text() -> String {
[
" Tab switch focus between the index list and the search bar",
" ↑ / ↓ move the index selection (when the list has focus)",
" All the top list row fans queries / stats across every index",
" / activate the inline index filter (Esc / Enter close)",
" s cycle index sort: Activity → Name → Chunks",
" g toggle grouping by inferred project",
" r reindex the selected index — or all, when 'All' is selected",
" Enter run a search against the selected index — or all of them",
" ? toggle this help overlay",
" q / Esc quit",
]
.join("\n")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexListRow {
pub text: String,
pub selected: bool,
pub is_all: bool,
pub is_header: bool,
}
fn index_row_flat(idx: &IndexRow, selected: bool) -> String {
let marker = if selected { ">" } else { " " };
format!(
"{marker} {:<12} {:>8} ✓",
truncate(&idx.id, 12),
format_count(idx.chunk_count),
)
}
fn index_row_indented(idx: &IndexRow, selected: bool) -> String {
let marker = if selected { ">" } else { " " };
format!(
"{marker} {:<11} {:>8} ✓",
truncate(&idx.id, 11),
format_count(idx.chunk_count),
)
}
pub fn index_lines(state: &SearchTuiState) -> Vec<IndexListRow> {
let mut rows: Vec<IndexListRow> = Vec::with_capacity(state.indexes.len() + 1);
let total_chunks: u64 = state.indexes.iter().map(|i| i.chunk_count).sum();
let all_selected = state.selected == 0;
let all_marker = if all_selected { ">" } else { " " };
rows.push(IndexListRow {
text: format!(
"{all_marker} {:<12} {:>8} ∗",
truncate(ALL_LABEL, 12),
format_count(total_chunks),
),
selected: all_selected,
is_all: true,
is_header: false,
});
if state.indexes.is_empty() {
let text = if state.daemon_status == DaemonStatus::Connecting {
" Loading…".to_string()
} else {
" (no indexes registered)".to_string()
};
rows.push(IndexListRow {
text,
selected: false,
is_all: false,
is_header: false,
});
return rows;
}
let visible = filtered_sorted_indexes(state);
if visible.is_empty() {
rows.push(IndexListRow {
text: " (no matches)".to_string(),
selected: false,
is_all: false,
is_header: false,
});
return rows;
}
let cursor_for = |idx: &IndexRow| -> usize {
state
.indexes
.iter()
.position(|orig| orig.id == idx.id)
.map(|i| i + 1)
.unwrap_or(0)
};
if state.group_by_project {
let mut seen: Vec<String> = Vec::new();
for i in &visible {
let proj = i.project().to_string();
if !seen.iter().any(|s| s == &proj) {
seen.push(proj);
}
}
for project in &seen {
rows.push(IndexListRow {
text: format!("── {project} ─────"),
selected: false,
is_all: false,
is_header: true,
});
for idx in visible.iter().filter(|i| i.project() == project) {
let cursor = cursor_for(idx);
let selected = cursor == state.selected;
rows.push(IndexListRow {
text: index_row_indented(idx, selected),
selected,
is_all: false,
is_header: false,
});
}
}
} else {
for idx in &visible {
let cursor = cursor_for(idx);
let selected = cursor == state.selected;
rows.push(IndexListRow {
text: index_row_flat(idx, selected),
selected,
is_all: false,
is_header: false,
});
}
}
rows
}
pub fn stats_lines(state: &SearchTuiState) -> Vec<String> {
if state.daemon_status == DaemonStatus::Connecting {
return vec!["Loading…".to_string()];
}
if state.is_all_selected() {
let total: u64 = state.indexes.iter().map(|i| i.chunk_count).sum();
let total_nodes: u64 = state.indexes.iter().map(|i| i.node_count).sum();
let mut lines = vec![
format!("Scope: {ALL_LABEL}"),
format!("Indexes: {}", state.indexes.len()),
format!("Total chunks: {}", format_count(total)),
];
if total_nodes > 0 {
lines.push(format!("Graph nodes: {}", format_count(total_nodes)));
} else {
lines.push("Graph nodes: (none — reindex to build)".to_string());
}
if state.indexes.is_empty() {
lines.push("(no indexes registered)".to_string());
} else {
lines.push(String::new());
for idx in &state.indexes {
lines.push(format!(
" · {:<14} {:>8}",
truncate(&idx.id, 14),
format_count(idx.chunk_count),
));
}
}
return lines;
}
match state.indexes.get(state.selected.saturating_sub(1)) {
Some(idx) => {
let mut lines = vec![
format!("Index: {}", idx.id),
format!("Chunks: {}", format_count(idx.chunk_count)),
format!(
"Root path: {}",
if idx.root_path.is_empty() {
"(unknown)"
} else {
idx.root_path.as_str()
}
),
];
if let Some(bytes) = idx.disk_bytes {
lines.push(format!("Disk size: {}", format_bytes(bytes)));
}
if let Some(when) = idx.last_indexed {
lines.push(format!(
"Last indexed: {}",
when.format("%Y-%m-%d %H:%M UTC")
));
}
lines.push(String::new());
lines.push("Graph:".to_string());
if idx.node_count == 0 {
lines.push(" (no graph — press [r] to reindex)".to_string());
} else {
lines.push(format!(
" Nodes: {:>8} Edges: {:>8}",
format_count(idx.node_count),
format_count(idx.edge_count),
));
if let Some(max) = idx.edge_kinds.iter().map(|(_, n)| *n).max()
&& max > 0
{
const BAR_WIDTH: usize = 14;
for (kind, count) in &idx.edge_kinds {
let bar_len =
((*count as f64 / max as f64) * BAR_WIDTH as f64).round() as usize;
let bar_len = bar_len.min(BAR_WIDTH);
let bar: String = "█".repeat(bar_len);
lines.push(format!(
" {:<18} {:>7} {}",
truncate(kind, 18),
format_count(*count),
bar,
));
}
}
}
lines
}
None => vec!["(no index selected)".to_string()],
}
}
pub fn format_bytes(bytes: u64) -> String {
const KB: f64 = 1024.0;
const MB: f64 = KB * 1024.0;
const GB: f64 = MB * 1024.0;
const TB: f64 = GB * 1024.0;
let n = bytes as f64;
if n < KB {
format!("{bytes} B")
} else if n < MB {
format!("{:.1} KB", n / KB)
} else if n < GB {
format!("{:.1} MB", n / MB)
} else if n < TB {
format!("{:.1} GB", n / GB)
} else {
format!("{:.1} TB", n / TB)
}
}
pub fn title_line(state: &SearchTuiState) -> String {
let (glyph, label) = state.daemon_status.badge();
match &state.daemon_status {
DaemonStatus::Online { uptime_secs, .. } => format!(
"trusty-search v{VERSION} [{glyph}] {label} uptime: {}",
fmt_uptime(*uptime_secs)
),
_ => format!(
"trusty-search v{VERSION} [{glyph}] {label} {}",
state.base_url
),
}
}
pub fn render(frame: &mut Frame, state: &mut SearchTuiState) {
let area = frame.area();
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(4),
Constraint::Length(3),
Constraint::Length(1),
])
.split(area);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
title_line(state),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
))),
rows[0],
);
let split = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(left_panel_width(area.width)),
Constraint::Min(10),
])
.split(rows[1]);
let list_focused = state.focus == SearchFocus::List;
let index_items: Vec<ListItem> = index_lines(state)
.into_iter()
.map(|row| {
let style = if row.selected {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else if row.is_header || row.is_all {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
ListItem::new(Line::from(Span::styled(row.text, style)))
})
.collect();
let show_filter_bar = state.filter_active || !state.filter.is_empty();
let (filter_area, list_area) = if show_filter_bar {
let inner = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(3)])
.split(split[0]);
(Some(inner[0]), inner[1])
} else {
(None, split[0])
};
if let Some(area) = filter_area {
let border_color = if state.filter_active {
Color::Yellow
} else {
Color::DarkGray
};
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
Span::styled(
state.filter.as_str().to_string(),
Style::default().fg(Color::White),
),
Span::styled(
if state.filter_active { "_" } else { "" },
Style::default().fg(Color::Cyan),
),
]))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(
Style::default()
.fg(border_color)
.add_modifier(Modifier::BOLD),
)
.title(Span::styled(
" FILTER ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)),
),
area,
);
}
let index_visible = list_area.height.saturating_sub(2) as usize;
let visible_row = visible_selected_row(state);
state.sync_scroll_to(visible_row, index_visible);
let mut index_state = ListState::default()
.with_offset(state.scroll_offset)
.with_selected(Some(visible_row));
let index_title = format!("INDEXES [{}]", sort_label(state.sort_key));
frame.render_stateful_widget(
List::new(index_items).block(panel_block(&index_title, list_focused)),
list_area,
&mut index_state,
);
let right = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(tui_common::ACTIVITY_PERCENT),
Constraint::Percentage(100 - tui_common::ACTIVITY_PERCENT),
])
.split(split[1]);
let scope = state.scope_filter();
let activity_title = match scope {
Some(id) => format!("ACTIVITY — {id}"),
None => format!("ACTIVITY — {ALL_LABEL}"),
};
let activity_height = right[0].height.saturating_sub(2) as usize;
let activity_items: Vec<ListItem> = if state.log.has_scoped(scope) {
state
.log
.tail_scoped(scope, activity_height.max(1))
.map(|line| ListItem::new(line.as_str()))
.collect()
} else if state.daemon_status == DaemonStatus::Connecting {
vec![ListItem::new("Loading…")]
} else {
vec![ListItem::new("(no activity yet)")]
};
frame.render_widget(
List::new(activity_items).block(panel_block(&activity_title, false)),
right[0],
);
let stats_items: Vec<ListItem> = stats_lines(state).into_iter().map(ListItem::new).collect();
frame.render_widget(
List::new(stats_items).block(panel_block("STATISTICS", false)),
right[1],
);
let input_focused = state.focus == SearchFocus::Input;
let cursor = if input_focused { "_" } else { "" };
let input_style = if input_focused {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::DarkGray)
};
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled("SEARCH ▶ ", Style::default().fg(Color::Yellow)),
Span::styled(format!("{}{cursor}", state.input), input_style),
]))
.block(panel_block("SEARCH", input_focused)),
rows[2],
);
frame.render_widget(
Paragraph::new(Line::from(Span::styled(
KEY_HINT,
Style::default().fg(Color::DarkGray),
))),
rows[3],
);
if state.show_help {
tui_common::render_help_overlay(frame, &help_text());
}
}