sabiql 1.10.0

A fast, driver-less TUI for browsing and editing PostgreSQL databases
Documentation
use std::time::Instant;

use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Wrap};

use crate::app::model::app_state::AppState;
use crate::app::model::sql_editor::modal::{HIGH_RISK_INPUT_VISIBLE_WIDTH, SqlModalStatus};
use crate::ui::primitives::atoms::text_cursor_spans;
use crate::ui::theme::ThemePalette;

pub fn render(
    frame: &mut Frame,
    area: Rect,
    state: &AppState,
    now: Instant,
    theme: &ThemePalette,
) -> u16 {
    // Inline EXPLAIN ANALYZE confirmation for destructive DML
    if let SqlModalStatus::ConfirmingAnalyzeHigh {
        query,
        input,
        target_name,
    } = state.sql_modal.status()
    {
        let lines = build_analyze_confirm_lines(area, query, input, target_name.as_deref(), theme);
        render_scrolled(frame, area, lines, state.explain.confirm_scroll_offset);
        return area.height;
    }

    if let Some(ref error) = state.explain.error {
        let lines: Vec<Line> = error
            .lines()
            .map(|line| {
                Line::from(Span::styled(
                    line.to_string(),
                    Style::default().fg(theme.status_error),
                ))
            })
            .collect();
        frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), area);
        area.height
    } else if let Some(ref plan_text) = state.explain.plan_text {
        let (label, label_style) = if state.explain.is_analyze {
            (
                "EXPLAIN ANALYZE",
                Style::default()
                    .fg(theme.text_accent)
                    .add_modifier(Modifier::BOLD),
            )
        } else {
            (
                "EXPLAIN",
                Style::default()
                    .fg(theme.text_accent)
                    .add_modifier(Modifier::BOLD),
            )
        };
        let time_secs = state.explain.execution_time_ms as f64 / 1000.0;
        let header = Line::from(vec![
            Span::styled(format!("{label} "), label_style),
            Span::styled(
                format!("({time_secs:.2}s)"),
                Style::default().fg(theme.text_muted),
            ),
        ]);

        let query_snippet = state.explain.plan_query_snippet.as_deref().unwrap_or("");
        let query_line = Line::from(vec![
            Span::styled("  ", Style::default()),
            Span::styled(
                query_snippet.to_string(),
                Style::default().fg(theme.text_muted),
            ),
        ]);

        let scroll = state.explain.scroll_offset;
        let mut lines = vec![header, query_line, Line::raw("")];
        lines.extend(
            plan_text
                .lines()
                .skip(scroll)
                .map(|line| super::plan_highlight::highlight_plan_line(line, theme)),
        );

        let flash_active = state.flash_timers.is_active(
            crate::app::model::shared::flash_timer::FlashId::SqlModal,
            now,
        );
        let content_start = 3; // skip header, query snippet, empty line
        crate::ui::primitives::atoms::apply_yank_flash(
            &mut lines[content_start..],
            flash_active,
            theme,
        );

        frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), area);
        area.height
    } else {
        let placeholder = Line::from(Span::styled(
            " Press Ctrl+E to run EXPLAIN",
            Style::default().fg(theme.placeholder_text),
        ));
        frame.render_widget(Paragraph::new(vec![placeholder]), area);
        area.height
    }
}

fn render_scrolled(frame: &mut Frame, area: Rect, lines: Vec<Line>, scroll_offset: usize) {
    let max_scroll = lines.len().saturating_sub(area.height as usize);
    let clamped = scroll_offset.min(max_scroll);
    let visible: Vec<Line> = lines.into_iter().skip(clamped).collect();
    frame.render_widget(Paragraph::new(visible).wrap(Wrap { trim: false }), area);
}

fn build_analyze_confirm_lines<'a>(
    area: Rect,
    query: &'a str,
    input: &'a crate::app::model::shared::text_input::TextInputState,
    target_name: Option<&'a str>,
    theme: &ThemePalette,
) -> Vec<Line<'a>> {
    let mut lines = Vec::new();

    let header_style = Style::default()
        .fg(theme.status_error)
        .add_modifier(Modifier::BOLD);

    lines.push(Line::raw(""));
    lines.push(Line::from(Span::styled(
        " \u{26a0} EXPLAIN ANALYZE",
        header_style,
    )));
    lines.push(Line::raw(""));

    let sep = "\u{2500}".repeat(area.width.saturating_sub(2) as usize);
    lines.push(Line::styled(
        format!(" {sep}"),
        Style::default().fg(theme.modal_border),
    ));
    lines.push(Line::raw(""));

    lines.push(Line::from(Span::styled(
        " This is a destructive statement. EXPLAIN ANALYZE will",
        header_style,
    )));
    lines.push(Line::from(Span::styled(
        " execute it and data loss may occur.",
        header_style,
    )));
    lines.push(Line::raw(""));

    let full_query = query;
    for line in full_query.lines() {
        lines.push(Line::from(Span::styled(
            format!("  {line}"),
            Style::default().fg(theme.text_dim),
        )));
    }
    lines.push(Line::raw(""));

    match target_name {
        Some(name) => {
            let is_match = input.content() == name;
            let prompt = format!(" Type \"{name}\" to confirm: > ");
            let mut prompt_spans = vec![Span::styled(
                prompt,
                Style::default().fg(theme.text_secondary),
            )];
            prompt_spans.extend(text_cursor_spans(
                input.content(),
                input.cursor(),
                input.viewport_offset(),
                HIGH_RISK_INPUT_VISIBLE_WIDTH,
                theme,
            ));
            if is_match {
                prompt_spans.push(Span::styled(
                    " \u{2713}",
                    Style::default().fg(theme.status_success),
                ));
            }
            lines.push(Line::from(prompt_spans));
        }
        None => {
            lines.push(Line::from(Span::styled(
                " Cannot identify target object name.  Esc: Back",
                Style::default().fg(theme.text_muted),
            )));
        }
    }

    lines
}