monoripple 0.0.5

Symbol-aware affected target detection for JavaScript and TypeScript monorepos
Documentation
use std::io::{self, IsTerminal};
use std::time::Duration;

use anyhow::{Result, bail};
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use crossterm::execute;
use crossterm::terminal::{
    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::{Color, Modifier, Style, Stylize};
use ratatui::text::{Line, Text};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap};

pub struct WhyUiModel {
    pub package: String,
    pub base: String,
    pub paths: Vec<Vec<WhyUiItem>>,
    pub cycles: Vec<Vec<String>>,
}

pub struct WhyUiItem {
    pub label: String,
    pub detail: String,
}

pub fn run(model: WhyUiModel) -> Result<()> {
    if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
        bail!("interactive why UI requires a terminal");
    }
    if model.paths.is_empty() || model.paths.iter().any(Vec::is_empty) {
        bail!("why UI has no impact path to display");
    }

    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;
    terminal.clear()?;
    let result = run_loop(&mut terminal, &model);
    disable_raw_mode()?;
    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
    terminal.show_cursor()?;
    result
}

fn run_loop(
    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
    model: &WhyUiModel,
) -> Result<()> {
    let mut state = ListState::default().with_selected(Some(0));
    let mut path_index = 0;

    loop {
        terminal.draw(|frame| {
            let rows = Layout::default()
                .direction(Direction::Vertical)
                .constraints([
                    Constraint::Length(3),
                    Constraint::Min(4),
                    Constraint::Length(1),
                ])
                .split(frame.area());
            let header = Paragraph::new(Line::from(vec![
                "Why ".into(),
                model.package.clone().bold().cyan(),
                " is affected".into(),
                format!("  path {}/{}", path_index + 1, model.paths.len()).yellow(),
                format!("  base: {}", model.base).dark_gray(),
            ]))
            .block(Block::default().borders(Borders::ALL));
            frame.render_widget(header, rows[0]);

            let body = if rows[1].width >= 90 {
                Layout::default()
                    .direction(Direction::Horizontal)
                    .constraints([Constraint::Percentage(45), Constraint::Percentage(55)])
                    .split(rows[1])
            } else {
                Layout::default()
                    .direction(Direction::Vertical)
                    .constraints([Constraint::Percentage(55), Constraint::Percentage(45)])
                    .split(rows[1])
            };
            let path = &model.paths[path_index];
            let items: Vec<_> = path
                .iter()
                .enumerate()
                .map(|(index, item)| {
                    let prefix = if index == 0 {
                        ""
                    } else if index + 1 == path.len() {
                        ""
                    } else {
                        ""
                    };
                    ListItem::new(format!("{prefix}{}", item.label))
                })
                .collect();
            let list = List::new(items)
                .block(Block::default().title("Impact path").borders(Borders::ALL))
                .highlight_style(
                    Style::default()
                        .fg(Color::Black)
                        .bg(Color::Cyan)
                        .add_modifier(Modifier::BOLD),
                );
            frame.render_stateful_widget(list, body[0], &mut state);

            let selected = state.selected().unwrap_or_default();
            let item = &path[selected];
            let mut details = vec![Line::from(item.detail.clone())];
            for cycle in &model.cycles {
                if cycle.iter().any(|member| {
                    item.label == *member || item.label.starts_with(&format!("{member}#"))
                }) {
                    details.push(Line::from(""));
                    details.push(Line::styled(
                        "Runtime module cycle",
                        Style::default()
                            .fg(Color::Yellow)
                            .add_modifier(Modifier::BOLD),
                    ));
                    details.extend(cycle.iter().map(|member| Line::from(format!("  {member}"))));
                }
            }
            let detail = Paragraph::new(Text::from(details))
                .block(
                    Block::default()
                        .title("Selected node")
                        .borders(Borders::ALL),
                )
                .wrap(Wrap { trim: false });
            frame.render_widget(detail, body[1]);
            frame.render_widget(
                Paragraph::new(
                    "↑/↓ or j/k steps   ←/→ or h/l paths   g/G first/last   q or Esc quit",
                )
                .style(Style::default().fg(Color::DarkGray)),
                rows[2],
            );
        })?;

        if event::poll(Duration::from_millis(250))?
            && let Event::Key(key) = event::read()?
            && key.kind == KeyEventKind::Press
        {
            let selected = state.selected().unwrap_or_default();
            match key.code {
                KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
                KeyCode::Down | KeyCode::Char('j') => {
                    state.select(Some((selected + 1).min(model.paths[path_index].len() - 1)));
                }
                KeyCode::Up | KeyCode::Char('k') => {
                    state.select(Some(selected.saturating_sub(1)));
                }
                KeyCode::Char('g') | KeyCode::Home => state.select(Some(0)),
                KeyCode::Char('G') | KeyCode::End => {
                    state.select(Some(model.paths[path_index].len() - 1));
                }
                KeyCode::Right | KeyCode::Char('l') => {
                    path_index = (path_index + 1).min(model.paths.len() - 1);
                    state.select(Some(0));
                }
                KeyCode::Left | KeyCode::Char('h') => {
                    path_index = path_index.saturating_sub(1);
                    state.select(Some(0));
                }
                _ => {}
            }
        }
    }
}