use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, List, ListItem, Paragraph},
Frame,
};
use sqlx::PgPool;
use crate::route_parser::{scan_routes_dir, RouteEntry};
use crate::tui::app::{copy_to_clipboard, AppAction};
#[derive(Default)]
pub struct RoutesTab {
routes: Vec<RouteEntry>,
filtered: Vec<usize>,
selected: usize,
filter: String,
filter_active: bool,
expanded: Option<usize>,
expanded_path: Option<String>,
error: Option<String>,
}
impl RoutesTab {
pub async fn load(&mut self, _pool: &PgPool) {
self.routes = match scan_routes_dir() {
Ok(r) => r,
Err(e) => {
self.error = Some(format!("Failed to scan routes: {e}"));
Vec::new()
}
};
self.apply_filter();
}
pub fn render(&mut self, frame: &mut Frame, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(1),
])
.split(area);
self.render_filter(frame, chunks[0]);
self.render_list(frame, chunks[1]);
self.render_help(frame, chunks[2]);
}
fn render_filter(&self, frame: &mut Frame, area: Rect) {
let style = if self.filter_active {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::DarkGray)
};
let text = if self.filter_active && !self.filter.is_empty() {
format!("/{}", self.filter)
} else if self.filter_active {
" / (type to filter by path)...".to_string()
} else {
format!(
" {} routes | press / to filter",
self.filtered.len()
)
};
let input = Paragraph::new(text)
.style(style)
.block(Block::default().borders(Borders::ALL).title(" Routes "));
frame.render_widget(input, area);
}
fn render_list(&self, frame: &mut Frame, area: Rect) {
if self.filtered.is_empty() {
let msg = if self.error.is_some() {
self.error.as_deref().unwrap_or("No routes found")
} else {
"No matching routes"
};
let p = Paragraph::new(Text::from(Line::from(Span::styled(
format!(" {msg}"),
Style::default().fg(Color::DarkGray),
))))
.block(Block::default().borders(Borders::ALL));
frame.render_widget(p, area);
return;
}
let show_expanded = self.expanded.is_some();
let items: Vec<ListItem> = self
.filtered
.iter()
.enumerate()
.map(|(i, &idx)| {
let route = &self.routes[idx];
let is_selected = i == self.selected;
let method_color = match route.method.as_str() {
"GET" => Color::Green,
"POST" => Color::Blue,
"PUT" => Color::Yellow,
"PATCH" => Color::Yellow,
"DELETE" => Color::Red,
"HEAD" => Color::Cyan,
_ => Color::White,
};
let mut spans = vec![
Span::styled(
format!(" {:7}", route.method),
Style::default()
.fg(method_color)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
&route.path,
Style::default().fg(if is_selected {
Color::White
} else {
Color::Cyan
}),
),
];
if let Some(ref name) = route.name {
spans.push(Span::raw(" "));
spans.push(Span::styled(
name,
Style::default().fg(Color::DarkGray),
));
}
let mw = route.middleware.join(", ");
if !mw.is_empty() {
spans.push(Span::raw(" "));
spans.push(Span::styled(
format!("[{mw}]"),
Style::default().fg(Color::Magenta),
));
}
let mut lines = vec![Line::from(spans)];
if is_selected && show_expanded {
if let Some(ref expanded_path) = self.expanded_path {
lines.push(Line::from(Span::styled(
format!(" Handler: {}", route.handler),
Style::default().fg(Color::Green),
)));
if let Some(ref ctrl) = route.controller {
lines.push(Line::from(Span::styled(
format!(" Controller: {ctrl}"),
Style::default().fg(Color::Green),
)));
}
if !mw.is_empty() {
lines.push(Line::from(Span::styled(
format!(" Middleware: {mw}"),
Style::default().fg(Color::Magenta),
)));
}
let _ = expanded_path;
}
}
ListItem::new(lines)
})
.collect();
let list = List::new(items)
.highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.block(Block::default().borders(Borders::ALL));
frame.render_widget(list, area);
}
fn render_help(&self, frame: &mut Frame, area: Rect) {
let keys = if self.filter_active {
vec![("Esc", "close filter"), ("Enter", "apply")]
} else {
vec![
("↑↓", "navigate"),
("Enter", "expand"),
("c", "copy path"),
("/", "filter"),
("r", "refresh"),
]
};
let spans: Vec<Span> = keys
.iter()
.flat_map(|(k, d)| {
vec![
Span::styled(format!(" {k}"), Style::default().fg(Color::Cyan)),
Span::raw(format!(" {d} ")),
]
})
.collect();
frame.render_widget(
Paragraph::new(Line::from(spans)).alignment(Alignment::Center),
area,
);
}
pub fn handle_key(&mut self, key: KeyEvent) -> AppAction {
if self.filter_active {
match key.code {
KeyCode::Esc => {
self.filter_active = false;
self.filter.clear();
self.apply_filter();
AppAction::None
}
KeyCode::Enter => {
self.filter_active = false;
self.apply_filter();
AppAction::None
}
KeyCode::Backspace => {
self.filter.pop();
self.apply_filter();
AppAction::None
}
KeyCode::Char(c) => {
self.filter.push(c);
self.apply_filter();
AppAction::None
}
_ => AppAction::None,
}
} else {
match key.code {
KeyCode::Up => {
self.selected = self.selected.saturating_sub(1);
AppAction::None
}
KeyCode::Down => {
if self.selected + 1 < self.filtered.len() {
self.selected += 1;
}
AppAction::None
}
KeyCode::Enter => {
if let Some(&idx) = self.filtered.get(self.selected) {
let route = &self.routes[idx];
if self.expanded == Some(idx) {
self.expanded = None;
self.expanded_path = None;
} else {
self.expanded = Some(idx);
self.expanded_path = Some(route.path.clone());
}
}
AppAction::None
}
KeyCode::Char('/') => {
self.filter_active = true;
self.filter.clear();
AppAction::None
}
KeyCode::Char('r') | KeyCode::F(5) => AppAction::Reload,
KeyCode::Char('c') => {
if let Some(&idx) = self.filtered.get(self.selected) {
let path = &self.routes[idx].path;
copy_to_clipboard(path);
}
AppAction::None
}
_ => AppAction::None,
}
}
}
fn apply_filter(&mut self) {
if self.filter.is_empty() {
self.filtered = (0..self.routes.len()).collect();
} else {
let f = self.filter.to_lowercase();
self.filtered = self
.routes
.iter()
.enumerate()
.filter(|(_, r)| r.path.to_lowercase().contains(&f) || r.method.to_lowercase().contains(&f))
.map(|(i, _)| i)
.collect();
}
self.selected = 0;
self.expanded = None;
self.expanded_path = None;
}
}