sabiql 1.10.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::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;

use super::json_tree::json_tree_line_spans;
use crate::app::model::app_state::AppState;
use crate::app::model::browse::jsonb_detail::JsonbDetailMode;
use crate::app::model::shared::text_input::TextInputLike;
use crate::ui::primitives::molecules::render_modal;
use crate::ui::theme::ThemePalette;

pub struct JsonbDetail;

impl JsonbDetail {
    pub fn render(
        frame: &mut Frame,
        state: &AppState,
        now: std::time::Instant,
        theme: &ThemePalette,
    ) -> Option<usize> {
        if !state.jsonb_detail.is_active() {
            return None;
        }

        match state.jsonb_detail.mode() {
            JsonbDetailMode::Viewing | JsonbDetailMode::Searching => {
                Some(Self::render_viewing(frame, state, now, theme))
            }
            JsonbDetailMode::Editing => {
                Self::render_editing(frame, state, theme);
                None
            }
        }
    }

    fn render_viewing(
        frame: &mut Frame,
        state: &AppState,
        now: std::time::Instant,
        theme: &ThemePalette,
    ) -> usize {
        let title = format!(
            " JSONB Detail \u{2500}\u{2500} {}",
            state.jsonb_detail.column_name()
        );
        let is_searching = state.jsonb_detail.search().active;
        let hint = if is_searching {
            " Enter:Confirm  Esc:Cancel "
        } else {
            " y:Copy  i:Edit  /:Search  h/l:Fold  Esc:Close "
        };

        let (_area, inner) = render_modal(
            frame,
            Constraint::Percentage(80),
            Constraint::Percentage(70),
            &title,
            hint,
            theme,
        );

        let has_changes = state.jsonb_detail.has_pending_changes();
        let bottom_rows = usize::from(has_changes) + usize::from(is_searching);
        let (tree_area, bottom_area) = if bottom_rows > 0 && inner.height >= 2 {
            let [t, b] =
                Layout::vertical([Constraint::Min(1), Constraint::Length(bottom_rows as u16)])
                    .areas(inner);
            (t, Some(b))
        } else {
            (inner, None)
        };

        let viewport_height = tree_area.height as usize;
        let scroll = state.jsonb_detail.adjusted_scroll(viewport_height);

        let tree = state.jsonb_detail.tree();
        let visible = state.jsonb_detail.visible_indices();
        let search = state.jsonb_detail.search();
        let selected = state.jsonb_detail.selected_line();
        let mut lines: Vec<Line<'_>> = visible
            .iter()
            .skip(scroll)
            .take(viewport_height)
            .enumerate()
            .map(|(view_idx, &real_idx)| {
                let is_selected = (scroll + view_idx) == selected;
                json_tree_line_spans(&tree.lines()[real_idx], is_selected, theme)
            })
            .collect();

        let flash_active = state.flash_timers.is_active(
            crate::app::model::shared::flash_timer::FlashId::JsonbDetail,
            now,
        );
        crate::ui::primitives::atoms::apply_yank_flash(&mut lines, flash_active, theme);

        let paragraph = Paragraph::new(lines).style(Style::default().fg(theme.text_primary));
        frame.render_widget(paragraph, tree_area);

        if let Some(bottom) = bottom_area {
            let mut bottom_lines: Vec<Line<'_>> = Vec::new();

            if has_changes {
                bottom_lines.push(Line::from(Span::styled(
                    "\u{25cf} Modified",
                    Style::default().fg(theme.cell_draft_pending_fg),
                )));
            }

            if is_searching {
                let query = search.input.content();
                let cursor_pos = search.input.cursor();
                let match_info: Span<'_> = if search.matches.is_empty() {
                    if query.is_empty() {
                        Span::raw("")
                    } else {
                        Span::styled(" [no matches]", Style::default().fg(theme.text_dim))
                    }
                } else {
                    Span::styled(
                        format!(" [{}/{}]", search.current_match + 1, search.matches.len()),
                        Style::default().fg(theme.text_dim),
                    )
                };
                let prefix = Span::styled("/", Style::default().fg(theme.text_accent));
                let cursor_spans = crate::ui::primitives::atoms::text_cursor_spans(
                    query,
                    cursor_pos,
                    search.input.viewport_offset(),
                    bottom.width.saturating_sub(1) as usize,
                    theme,
                );
                let mut spans = vec![prefix];
                spans.extend(cursor_spans);
                spans.push(match_info);
                bottom_lines.push(Line::from(spans));
            }

            frame.render_widget(Paragraph::new(bottom_lines), bottom);
        }

        scroll
    }

    fn render_editing(frame: &mut Frame, state: &AppState, theme: &ThemePalette) {
        let title = format!(
            " JSONB Edit \u{2500}\u{2500} {} (jsonb) ",
            state.jsonb_detail.column_name()
        );
        let hint = " Esc:Back (applies valid changes) ";

        let (_area, inner) = render_modal(
            frame,
            Constraint::Percentage(80),
            Constraint::Percentage(70),
            &title,
            hint,
            theme,
        );

        let [editor_area, status_area] =
            Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner);

        Self::render_editor_content(frame, editor_area, state, theme);
        Self::render_validation_status(frame, status_area, state, theme);
    }

    fn render_editor_content(
        frame: &mut Frame,
        area: Rect,
        state: &AppState,
        theme: &ThemePalette,
    ) {
        let editor = state.jsonb_detail.editor();
        let content = editor.content();
        let scroll_row = editor.scroll_row();
        let viewport_height = area.height as usize;
        let (cursor_row, cursor_col) = editor.cursor_to_position();
        let current_line_style = Style::default().bg(theme.editor_current_line_bg);

        let mut lines: Vec<Line<'_>> = content
            .lines()
            .enumerate()
            .skip(scroll_row)
            .take(viewport_height)
            .map(|(row, line_str)| {
                if row == cursor_row {
                    Line::from(crate::ui::primitives::atoms::text_cursor_spans(
                        line_str,
                        cursor_col,
                        0,
                        usize::MAX,
                        theme,
                    ))
                    .style(current_line_style)
                } else {
                    Line::from(Span::raw(line_str.to_owned()))
                }
            })
            .collect();

        // Cursor on empty trailing line after final newline
        if content.ends_with('\n') && cursor_row == content.lines().count() {
            lines.push(
                Line::from(vec![Span::styled(
                    "\u{258F}",
                    Style::default().fg(theme.cursor_fg),
                )])
                .style(current_line_style),
            );
        }

        let paragraph = Paragraph::new(lines).style(Style::default().fg(theme.text_primary));
        frame.render_widget(paragraph, area);
    }

    fn render_validation_status(
        frame: &mut Frame,
        area: Rect,
        state: &AppState,
        theme: &ThemePalette,
    ) {
        let line = if let Some(err) = state.jsonb_detail.validation_error() {
            Line::from(Span::styled(
                format!("\u{2717} {err}"),
                Style::default().fg(theme.status_error),
            ))
        } else {
            Line::from(Span::styled(
                "\u{2713} Valid JSON",
                Style::default().fg(theme.status_success),
            ))
        };

        frame.render_widget(Paragraph::new(line), area);
    }
}