sabiql 1.9.0

A fast, driver-less TUI for browsing and editing PostgreSQL databases
Documentation
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;

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::{spinner_char, text_cursor_spans};
use crate::ui::theme::Theme;

pub(super) fn render_status(frame: &mut Frame, area: Rect, state: &AppState) {
    if let SqlModalStatus::ConfirmingHigh {
        decision,
        input,
        target_name,
    } = state.sql_modal.status()
    {
        render_confirming_high_status(frame, area, decision, input, target_name);
        return;
    }

    let (badge_text, badge_style, status_text, status_style) = match state.sql_modal.status() {
        SqlModalStatus::Normal => {
            if let Some(ref msg) = state.messages.last_success {
                (
                    "[NORMAL]",
                    Style::default().fg(Theme::TEXT_DIM),
                    format!("\u{2713} {}", msg),
                    Style::default().fg(Theme::STATUS_SUCCESS),
                )
            } else {
                (
                    "[NORMAL]",
                    Style::default().fg(Theme::TEXT_DIM),
                    "Ready".to_string(),
                    Style::default().fg(Theme::TEXT_DIM),
                )
            }
        }
        SqlModalStatus::Editing => (
            "[INSERT]",
            Style::default()
                .fg(Theme::TEXT_ACCENT)
                .add_modifier(Modifier::BOLD),
            "Ready".to_string(),
            Style::default().fg(Theme::TEXT_DIM),
        ),
        SqlModalStatus::Confirming(decision) => {
            let text = format!(
                "\u{26a0} {} RISK  {}",
                decision.risk_level.as_str(),
                decision.label
            );
            let color = Style::default().fg(Theme::risk_color(decision.risk_level));
            ("[CONFIRM]", color, text, color)
        }
        SqlModalStatus::Running => {
            let elapsed = state
                .query
                .start_time()
                .map(|t| t.elapsed())
                .unwrap_or_default();
            let spinner = spinner_char(elapsed.as_millis());
            let elapsed_secs = elapsed.as_secs_f32();
            let status = format!("{} Running {:.1}s", spinner, elapsed_secs);
            (
                "[RUNNING]",
                Style::default().fg(Theme::TEXT_ACCENT),
                status,
                Style::default().fg(Theme::TEXT_ACCENT),
            )
        }
        SqlModalStatus::Success => {
            let msg = success_status_message(state);
            (
                "[NORMAL]",
                Style::default().fg(Theme::STATUS_SUCCESS),
                msg,
                Style::default()
                    .fg(Theme::STATUS_SUCCESS)
                    .add_modifier(Modifier::BOLD),
            )
        }
        SqlModalStatus::Error => {
            let msg = error_status_message(state);
            (
                "[NORMAL]",
                Style::default().fg(Theme::STATUS_ERROR),
                msg,
                Style::default()
                    .fg(Theme::STATUS_ERROR)
                    .add_modifier(Modifier::BOLD),
            )
        }
        SqlModalStatus::ConfirmingAnalyze { is_dml, .. } => {
            let color = if *is_dml {
                Theme::STATUS_ERROR
            } else {
                Theme::STATUS_WARNING
            };
            (
                "[CONFIRM]",
                Style::default().fg(color).add_modifier(Modifier::BOLD),
                "Confirm ANALYZE".to_string(),
                Style::default().fg(color).add_modifier(Modifier::BOLD),
            )
        }
        SqlModalStatus::ConfirmingAnalyzeHigh { .. } => (
            "[CONFIRM]",
            Style::default()
                .fg(Theme::STATUS_ERROR)
                .add_modifier(Modifier::BOLD),
            "Confirm ANALYZE".to_string(),
            Style::default()
                .fg(Theme::STATUS_ERROR)
                .add_modifier(Modifier::BOLD),
        ),
        SqlModalStatus::ConfirmingHigh { .. } => unreachable!(),
    };

    let badge_display = format!(" {}", badge_text);
    let badge_width = badge_display.len() as u16;
    let [badge_area, status_area] =
        Layout::horizontal([Constraint::Length(badge_width + 1), Constraint::Min(1)]).areas(area);

    let badge_line = Line::from(Span::styled(badge_display, badge_style));
    frame.render_widget(Paragraph::new(badge_line), badge_area);

    let status_display = format!("{} ", status_text);
    let status_line = Line::from(vec![Span::styled(status_display, status_style)]);
    frame.render_widget(
        Paragraph::new(status_line).alignment(ratatui::layout::Alignment::Right),
        status_area,
    );
}

fn truncate_with_ellipsis(s: &str, max_chars: usize) -> String {
    if max_chars == 0 {
        return "\u{2026}".to_string();
    }
    let char_count = s.chars().count();
    if char_count <= max_chars {
        return s.to_string();
    }
    let truncated: String = s.chars().take(max_chars.saturating_sub(1)).collect();
    format!("{}\u{2026}", truncated)
}

fn render_confirming_high_status(
    frame: &mut Frame,
    area: Rect,
    decision: &crate::app::policy::write::write_guardrails::AdhocRiskDecision,
    input: &crate::app::model::shared::text_input::TextInputState,
    target_name: &Option<String>,
) {
    let error_style = Style::default().fg(Theme::STATUS_ERROR);

    match target_name {
        Some(name) => {
            let is_match = input.content() == name;
            let warning_text = format!("\u{26a0} HIGH RISK  {}", decision.label);
            let blocked_label = "Enter blocked";
            let mut line1_spans = vec![Span::styled(warning_text.clone(), error_style)];
            if !is_match {
                let used = (warning_text.len() + blocked_label.len()) as u16;
                let padding = area.width.saturating_sub(used).max(2);
                line1_spans.push(Span::raw(" ".repeat(padding as usize)));
                line1_spans.push(Span::styled(
                    blocked_label,
                    Style::default().fg(Theme::TEXT_MUTED),
                ));
            }
            let line1 = Line::from(line1_spans);

            let prompt_fixed_len = "Confirm \"\": > ".len();
            let max_name_display = (area.width as usize)
                .saturating_sub(prompt_fixed_len + HIGH_RISK_INPUT_VISIBLE_WIDTH + 2);
            let display_name = truncate_with_ellipsis(name, max_name_display);
            let prompt = format!("Confirm \"{}\": > ", display_name);
            let visible_width = HIGH_RISK_INPUT_VISIBLE_WIDTH;
            let cursor_spans = text_cursor_spans(
                input.content(),
                input.cursor(),
                input.viewport_offset(),
                visible_width,
            );
            let mut line2_spans = vec![Span::styled(
                prompt,
                Style::default().fg(Theme::TEXT_SECONDARY),
            )];
            line2_spans.extend(cursor_spans);
            if is_match {
                line2_spans.push(Span::styled(
                    " \u{2713}",
                    Style::default().fg(Theme::STATUS_SUCCESS),
                ));
            }
            let line2 = Line::from(line2_spans);

            let paragraph = Paragraph::new(vec![line1, line2]);
            frame.render_widget(paragraph, area);
        }
        None => {
            let line1 = Line::from(Span::styled(
                format!("\u{26a0} HIGH RISK  {}", decision.label),
                error_style,
            ));
            let line2 = Line::from(Span::styled(
                "Cannot execute: unable to identify target table.  Esc: Back",
                Style::default().fg(Theme::TEXT_MUTED),
            ));
            let paragraph = Paragraph::new(vec![line1, line2]);
            frame.render_widget(paragraph, area);
        }
    }
}

fn success_status_message(state: &AppState) -> String {
    let Some(snapshot) = state.sql_modal.last_adhoc_success() else {
        return "\u{2713} OK".to_string();
    };
    let time_secs = snapshot.execution_time_ms as f64 / 1000.0;

    if let Some(tag) = snapshot.command_tag.as_ref() {
        format!("\u{2713} {} ({:.2}s)", tag.display_message(), time_secs)
    } else {
        let rows_label = if snapshot.row_count == 1 {
            "row"
        } else {
            "rows"
        };
        format!(
            "\u{2713} {} {} ({:.2}s)",
            snapshot.row_count, rows_label, time_secs
        )
    }
}

fn error_status_message(state: &AppState) -> String {
    state
        .sql_modal
        .last_adhoc_error()
        .and_then(|e| e.lines().next())
        .map(|line| format!("\u{2717} {}", line))
        .unwrap_or_else(|| "\u{2717} Error".to_string())
}

#[cfg(test)]
mod tests {
    use super::*;
    use rstest::rstest;

    mod truncate_with_ellipsis_tests {
        use super::*;

        #[rstest]
        #[case("users", 16, "users")]
        #[case("user_sessions", 16, "user_sessions")]
        #[case("exactly_16_chars", 16, "exactly_16_chars")]
        #[case("public.user_sessions", 16, "public.user_ses\u{2026}")]
        #[case("my_schema.very_long_table_name", 16, "my_schema.very_\u{2026}")]
        #[case("ab", 1, "\u{2026}")]
        fn truncates_long_names(#[case] input: &str, #[case] max: usize, #[case] expected: &str) {
            assert_eq!(truncate_with_ellipsis(input, max), expected);
        }

        #[test]
        fn zero_max_returns_ellipsis() {
            assert_eq!(truncate_with_ellipsis("anything", 0), "\u{2026}");
        }

        #[test]
        fn multibyte_truncates_by_char_count() {
            let result = truncate_with_ellipsis("テーブル名前", 4);

            assert_eq!(result, "テーブ\u{2026}");
        }
    }
}