sabiql 1.6.2

A fast, driver-less TUI for browsing and editing PostgreSQL databases
Documentation
//! SQL modal sub-reducer: SQL editing and completion.

use std::time::{Duration, Instant};

use crate::app::action::{Action, CursorMove};
use crate::app::effect::Effect;
use crate::app::input_mode::InputMode;
use crate::app::reducers::{char_count, char_to_byte_index};
use crate::app::sql_modal_context::SqlModalStatus;
use crate::app::state::AppState;

/// Handles SQL modal editing and completion actions.
/// Returns Some(effects) if action was handled, None otherwise.
pub fn reduce_sql_modal(
    state: &mut AppState,
    action: &Action,
    now: Instant,
) -> Option<Vec<Effect>> {
    match action {
        // Completion navigation
        Action::CompletionNext => {
            if !state.sql_modal.completion.candidates.is_empty() {
                let max = state.sql_modal.completion.candidates.len() - 1;
                state.sql_modal.completion.selected_index =
                    if state.sql_modal.completion.selected_index >= max {
                        0
                    } else {
                        state.sql_modal.completion.selected_index + 1
                    };
            }
            Some(vec![])
        }
        Action::CompletionPrev => {
            if !state.sql_modal.completion.candidates.is_empty() {
                let max = state.sql_modal.completion.candidates.len() - 1;
                state.sql_modal.completion.selected_index =
                    if state.sql_modal.completion.selected_index == 0 {
                        max
                    } else {
                        state.sql_modal.completion.selected_index - 1
                    };
            }
            Some(vec![])
        }
        Action::CompletionDismiss => {
            state.sql_modal.completion.visible = false;
            state.sql_modal.completion_debounce = None;
            Some(vec![])
        }

        // Clipboard paste
        Action::Paste(text) if state.ui.input_mode == InputMode::SqlModal => {
            let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
            let byte_idx = char_to_byte_index(&state.sql_modal.content, state.sql_modal.cursor);
            state.sql_modal.content.insert_str(byte_idx, &normalized);
            state.sql_modal.cursor += normalized.chars().count();
            state.sql_modal.completion.visible = false;
            state.sql_modal.completion_debounce = Some(now + Duration::from_millis(100));
            state.sql_modal.status = SqlModalStatus::Editing;
            Some(vec![])
        }

        // Text editing
        Action::SqlModalInput(c) => {
            state.sql_modal.status = SqlModalStatus::Editing;
            let byte_idx = char_to_byte_index(&state.sql_modal.content, state.sql_modal.cursor);
            state.sql_modal.content.insert(byte_idx, *c);
            state.sql_modal.cursor += 1;
            state.sql_modal.completion_debounce = Some(now + Duration::from_millis(100));
            Some(vec![])
        }
        Action::SqlModalBackspace => {
            state.sql_modal.status = SqlModalStatus::Editing;
            if state.sql_modal.cursor > 0 {
                state.sql_modal.cursor -= 1;
                let byte_idx = char_to_byte_index(&state.sql_modal.content, state.sql_modal.cursor);
                state.sql_modal.content.remove(byte_idx);
            }
            state.sql_modal.completion_debounce = Some(now + Duration::from_millis(100));
            Some(vec![])
        }
        Action::SqlModalDelete => {
            state.sql_modal.status = SqlModalStatus::Editing;
            let total_chars = char_count(&state.sql_modal.content);
            if state.sql_modal.cursor < total_chars {
                let byte_idx = char_to_byte_index(&state.sql_modal.content, state.sql_modal.cursor);
                state.sql_modal.content.remove(byte_idx);
            }
            state.sql_modal.completion_debounce = Some(now + Duration::from_millis(100));
            Some(vec![])
        }
        Action::SqlModalNewLine => {
            state.sql_modal.status = SqlModalStatus::Editing;
            let byte_idx = char_to_byte_index(&state.sql_modal.content, state.sql_modal.cursor);
            state.sql_modal.content.insert(byte_idx, '\n');
            state.sql_modal.cursor += 1;
            state.sql_modal.completion_debounce = Some(now + Duration::from_millis(100));
            Some(vec![])
        }
        Action::SqlModalTab => {
            state.sql_modal.status = SqlModalStatus::Editing;
            let byte_idx = char_to_byte_index(&state.sql_modal.content, state.sql_modal.cursor);
            state.sql_modal.content.insert_str(byte_idx, "    ");
            state.sql_modal.cursor += 4;
            state.sql_modal.completion_debounce = Some(now + Duration::from_millis(100));
            Some(vec![])
        }
        Action::SqlModalMoveCursor(movement) => {
            let content = &state.sql_modal.content;
            let cursor = state.sql_modal.cursor;
            let total_chars = char_count(content);

            let lines: Vec<(usize, usize)> = {
                let mut result = Vec::new();
                let mut start = 0;
                for line in content.split('\n') {
                    let len = line.chars().count();
                    result.push((start, len));
                    start += len + 1;
                }
                result
            };

            let (current_line, current_col) = {
                let mut line_idx = 0;
                let mut col = cursor;
                for (i, (start, len)) in lines.iter().enumerate() {
                    if cursor >= *start && cursor <= start + len {
                        line_idx = i;
                        col = cursor - start;
                        break;
                    }
                }
                (line_idx, col)
            };

            state.sql_modal.cursor = match movement {
                CursorMove::Left => cursor.saturating_sub(1),
                CursorMove::Right => (cursor + 1).min(total_chars),
                CursorMove::Home => lines.get(current_line).map(|(s, _)| *s).unwrap_or(0),
                CursorMove::End => lines
                    .get(current_line)
                    .map(|(s, l)| s + l)
                    .unwrap_or(total_chars),
                CursorMove::Up => {
                    if current_line == 0 {
                        cursor
                    } else {
                        let (prev_start, prev_len) = lines[current_line - 1];
                        prev_start + current_col.min(prev_len)
                    }
                }
                CursorMove::Down => {
                    if current_line + 1 >= lines.len() {
                        cursor
                    } else {
                        let (next_start, next_len) = lines[current_line + 1];
                        next_start + current_col.min(next_len)
                    }
                }
            };
            Some(vec![])
        }
        Action::SqlModalClear => {
            state.sql_modal.content.clear();
            state.sql_modal.cursor = 0;
            state.sql_modal.completion.visible = false;
            state.sql_modal.completion.candidates.clear();
            Some(vec![])
        }

        // Modal open/submit
        Action::OpenSqlModal => {
            state.ui.input_mode = InputMode::SqlModal;
            state.sql_modal.status = SqlModalStatus::Editing;
            state.sql_modal.completion.visible = false;
            state.sql_modal.completion.candidates.clear();
            state.sql_modal.completion.selected_index = 0;
            state.sql_modal.completion_debounce = None;
            if !state.sql_modal.prefetch_started && state.cache.metadata.is_some() {
                Some(vec![Effect::DispatchActions(vec![
                    Action::StartPrefetchAll,
                ])])
            } else {
                Some(vec![])
            }
        }
        Action::SqlModalSubmit => {
            let query = state.sql_modal.content.trim().to_string();
            if !query.is_empty() {
                state.sql_modal.status = SqlModalStatus::Running;
                state.sql_modal.completion.visible = false;
                if let Some(dsn) = &state.runtime.dsn {
                    Some(vec![Effect::ExecuteAdhoc {
                        dsn: dsn.clone(),
                        query,
                    }])
                } else {
                    Some(vec![])
                }
            } else {
                Some(vec![])
            }
        }

        // Completion accept
        Action::CompletionAccept => {
            if state.sql_modal.completion.visible
                && !state.sql_modal.completion.candidates.is_empty()
            {
                let selected_idx = state.sql_modal.completion.selected_index;
                let trigger_pos = state.sql_modal.completion.trigger_position;
                let candidates = std::mem::take(&mut state.sql_modal.completion.candidates);

                if let Some(candidate) = candidates.into_iter().nth(selected_idx) {
                    let start_byte = char_to_byte_index(&state.sql_modal.content, trigger_pos);
                    let end_byte =
                        char_to_byte_index(&state.sql_modal.content, state.sql_modal.cursor);
                    state.sql_modal.content.drain(start_byte..end_byte);
                    state
                        .sql_modal
                        .content
                        .insert_str(start_byte, &candidate.text);
                    state.sql_modal.cursor = trigger_pos + candidate.text.chars().count();
                }
                state.sql_modal.completion.visible = false;
                state.sql_modal.completion_debounce = None;
            }
            Some(vec![])
        }

        // Completion trigger/update
        Action::CompletionTrigger => Some(vec![Effect::TriggerCompletion]),
        Action::CompletionUpdated {
            candidates,
            trigger_position,
            visible,
        } => {
            state.sql_modal.completion.candidates = candidates.clone();
            state.sql_modal.completion.trigger_position = *trigger_position;
            state.sql_modal.completion.visible = *visible;
            state.sql_modal.completion.selected_index = 0;
            Some(vec![])
        }

        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::time::Instant;

    mod paste {
        use super::*;

        fn sql_modal_state() -> AppState {
            let mut state = AppState::new("test".to_string());
            state.ui.input_mode = InputMode::SqlModal;
            state
        }

        #[test]
        fn paste_inserts_at_cursor() {
            let mut state = sql_modal_state();
            state.sql_modal.content = "SELCT".to_string();
            state.sql_modal.cursor = 3;

            reduce_sql_modal(&mut state, &Action::Paste("E".to_string()), Instant::now());

            assert_eq!(state.sql_modal.content, "SELECT");
        }

        #[test]
        fn paste_preserves_newlines() {
            let mut state = sql_modal_state();

            reduce_sql_modal(
                &mut state,
                &Action::Paste("SELECT\n*\nFROM".to_string()),
                Instant::now(),
            );

            assert_eq!(state.sql_modal.content, "SELECT\n*\nFROM");
        }

        #[test]
        fn paste_normalizes_crlf() {
            let mut state = sql_modal_state();

            reduce_sql_modal(
                &mut state,
                &Action::Paste("a\r\nb".to_string()),
                Instant::now(),
            );

            assert_eq!(state.sql_modal.content, "a\nb");
        }

        #[test]
        fn paste_advances_cursor() {
            let mut state = sql_modal_state();
            state.sql_modal.content = "AB".to_string();
            state.sql_modal.cursor = 1;

            reduce_sql_modal(
                &mut state,
                &Action::Paste("XYZ".to_string()),
                Instant::now(),
            );

            assert_eq!(state.sql_modal.cursor, 4); // 1 + 3
        }

        #[test]
        fn paste_dismisses_completion() {
            let mut state = sql_modal_state();
            state.sql_modal.completion.visible = true;

            reduce_sql_modal(&mut state, &Action::Paste("x".to_string()), Instant::now());

            assert!(!state.sql_modal.completion.visible);
        }

        #[test]
        fn paste_with_multibyte() {
            let mut state = sql_modal_state();
            state.sql_modal.content = "ab".to_string();
            state.sql_modal.cursor = 1;

            reduce_sql_modal(
                &mut state,
                &Action::Paste("日本語".to_string()),
                Instant::now(),
            );

            assert_eq!(state.sql_modal.content, "a日本語b");
            assert_eq!(state.sql_modal.cursor, 4); // 1 + 3
        }
    }
}