rok-cli 0.6.1

Developer CLI for rok-based Axum applications
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;
    }
}