use std::time::Instant;
use crate::app::action::{Action, ConnectionTarget, CursorMove};
use crate::app::connection_setup_state::{CONNECTION_INPUT_VISIBLE_WIDTH, ConnectionField};
use crate::app::connection_state::ConnectionState;
use crate::app::effect::Effect;
use crate::app::input_mode::InputMode;
use crate::app::reducers::{
insert_char_at_cursor, insert_str_at_cursor, validate_all, validate_field,
};
use crate::app::state::AppState;
use crate::domain::MetadataState;
use crate::domain::connection::SslMode;
pub fn reduce(state: &mut AppState, action: &Action, now: Instant) -> Option<Vec<Effect>> {
match action {
Action::OpenConnectionSetup => {
state.connection_setup.reset();
if !state.connections().is_empty() || state.session.dsn.is_some() {
state.connection_setup.is_first_run = false;
}
state.modal.set_mode(InputMode::ConnectionSetup);
Some(vec![])
}
Action::StartEditConnection(id) => {
Some(vec![Effect::LoadConnectionForEdit { id: id.clone() }])
}
Action::ConnectionEditLoaded(profile) => {
state.connection_setup =
crate::app::connection_setup_state::ConnectionSetupState::from_profile(profile);
state.modal.set_mode(InputMode::ConnectionSetup);
Some(vec![])
}
Action::ConnectionEditLoadFailed(msg) => {
state.messages.set_error_at(msg.clone(), now);
Some(vec![])
}
Action::CloseConnectionSetup => {
state.modal.set_mode(InputMode::Normal);
Some(vec![])
}
Action::Paste(text) if state.modal.active_mode() == InputMode::ConnectionSetup => {
let clean: String = text.chars().filter(|c| *c != '\n' && *c != '\r').collect();
let setup = &mut state.connection_setup;
match setup.focused_field {
ConnectionField::Port => {
let current_len = setup.port.chars().count();
let remaining = 5usize.saturating_sub(current_len);
let digits: String = clean
.chars()
.filter(|c| c.is_ascii_digit())
.take(remaining)
.collect();
if !digits.is_empty() {
let inserted =
insert_str_at_cursor(&mut setup.port, setup.cursor_position, &digits);
let new_cursor = setup.cursor_position + inserted;
setup.update_cursor(new_cursor, CONNECTION_INPUT_VISIBLE_WIDTH);
}
}
ConnectionField::SslMode => {}
_ => {
let field_str = match setup.focused_field {
ConnectionField::Name => &mut setup.name,
ConnectionField::Host => &mut setup.host,
ConnectionField::Database => &mut setup.database,
ConnectionField::User => &mut setup.user,
ConnectionField::Password => &mut setup.password,
_ => unreachable!(),
};
let inserted = insert_str_at_cursor(field_str, setup.cursor_position, &clean);
let new_cursor = setup.cursor_position + inserted;
setup.update_cursor(new_cursor, CONNECTION_INPUT_VISIBLE_WIDTH);
}
}
Some(vec![])
}
Action::ConnectionSetupInput(c) => {
let setup = &mut state.connection_setup;
match setup.focused_field {
ConnectionField::Name => {
insert_char_at_cursor(&mut setup.name, setup.cursor_position, *c);
let new_cursor = setup.cursor_position + 1;
setup.update_cursor(new_cursor, CONNECTION_INPUT_VISIBLE_WIDTH);
}
ConnectionField::Host => {
insert_char_at_cursor(&mut setup.host, setup.cursor_position, *c);
let new_cursor = setup.cursor_position + 1;
setup.update_cursor(new_cursor, CONNECTION_INPUT_VISIBLE_WIDTH);
}
ConnectionField::Port => {
if c.is_ascii_digit() && setup.port.chars().count() < 5 {
insert_char_at_cursor(&mut setup.port, setup.cursor_position, *c);
let new_cursor = setup.cursor_position + 1;
setup.update_cursor(new_cursor, CONNECTION_INPUT_VISIBLE_WIDTH);
}
}
ConnectionField::Database => {
insert_char_at_cursor(&mut setup.database, setup.cursor_position, *c);
let new_cursor = setup.cursor_position + 1;
setup.update_cursor(new_cursor, CONNECTION_INPUT_VISIBLE_WIDTH);
}
ConnectionField::User => {
insert_char_at_cursor(&mut setup.user, setup.cursor_position, *c);
let new_cursor = setup.cursor_position + 1;
setup.update_cursor(new_cursor, CONNECTION_INPUT_VISIBLE_WIDTH);
}
ConnectionField::Password => {
insert_char_at_cursor(&mut setup.password, setup.cursor_position, *c);
let new_cursor = setup.cursor_position + 1;
setup.update_cursor(new_cursor, CONNECTION_INPUT_VISIBLE_WIDTH);
}
ConnectionField::SslMode => {}
}
Some(vec![])
}
Action::ConnectionSetupBackspace => {
let setup = &mut state.connection_setup;
if setup.cursor_position == 0 {
return Some(vec![]);
}
let field_str = match setup.focused_field {
ConnectionField::Name => &mut setup.name,
ConnectionField::Host => &mut setup.host,
ConnectionField::Port => &mut setup.port,
ConnectionField::Database => &mut setup.database,
ConnectionField::User => &mut setup.user,
ConnectionField::Password => &mut setup.password,
ConnectionField::SslMode => return Some(vec![]),
};
let char_pos = setup.cursor_position - 1;
if let Some((byte_idx, _)) = field_str.char_indices().nth(char_pos) {
field_str.remove(byte_idx);
setup.update_cursor(char_pos, CONNECTION_INPUT_VISIBLE_WIDTH);
}
Some(vec![])
}
Action::ConnectionSetupMoveCursor(movement) => {
let setup = &mut state.connection_setup;
let field_str = match setup.focused_field {
ConnectionField::Name => &setup.name,
ConnectionField::Host => &setup.host,
ConnectionField::Port => &setup.port,
ConnectionField::Database => &setup.database,
ConnectionField::User => &setup.user,
ConnectionField::Password => &setup.password,
ConnectionField::SslMode => return Some(vec![]),
};
let len = field_str.chars().count();
let new_pos = match movement {
CursorMove::Left => setup.cursor_position.saturating_sub(1),
CursorMove::Right => (setup.cursor_position + 1).min(len),
CursorMove::Home => 0,
CursorMove::End => len,
CursorMove::Up | CursorMove::Down => return Some(vec![]),
};
setup.update_cursor(new_pos, CONNECTION_INPUT_VISIBLE_WIDTH);
Some(vec![])
}
Action::ConnectionSetupNextField => {
let setup = &mut state.connection_setup;
validate_field(setup, setup.focused_field);
if let Some(next) = setup.focused_field.next() {
setup.focused_field = next;
setup.cursor_to_end();
}
Some(vec![])
}
Action::ConnectionSetupPrevField => {
let setup = &mut state.connection_setup;
validate_field(setup, setup.focused_field);
if let Some(prev) = setup.focused_field.prev() {
setup.focused_field = prev;
setup.cursor_to_end();
}
Some(vec![])
}
Action::ConnectionSetupToggleDropdown => {
let setup = &mut state.connection_setup;
if setup.focused_field == ConnectionField::SslMode {
setup.ssl_dropdown.is_open = !setup.ssl_dropdown.is_open;
if setup.ssl_dropdown.is_open {
setup.ssl_dropdown.selected_index = SslMode::all_variants()
.iter()
.position(|v| *v == setup.ssl_mode)
.unwrap_or(2);
}
}
Some(vec![])
}
Action::ConnectionSetupDropdownNext => {
let setup = &mut state.connection_setup;
if setup.ssl_dropdown.is_open {
let max = SslMode::all_variants().len() - 1;
if setup.ssl_dropdown.selected_index < max {
setup.ssl_dropdown.selected_index += 1;
}
}
Some(vec![])
}
Action::ConnectionSetupDropdownPrev => {
let setup = &mut state.connection_setup;
if setup.ssl_dropdown.is_open {
setup.ssl_dropdown.selected_index =
setup.ssl_dropdown.selected_index.saturating_sub(1);
}
Some(vec![])
}
Action::ConnectionSetupDropdownConfirm => {
let setup = &mut state.connection_setup;
if setup.ssl_dropdown.is_open {
if let Some(mode) = SslMode::all_variants().get(setup.ssl_dropdown.selected_index) {
setup.ssl_mode = *mode;
}
setup.ssl_dropdown.is_open = false;
}
Some(vec![])
}
Action::ConnectionSetupDropdownCancel => {
state.connection_setup.ssl_dropdown.is_open = false;
Some(vec![])
}
Action::ConnectionSetupSave => {
let setup = &mut state.connection_setup;
validate_all(setup);
if setup.validation_errors.is_empty() {
let port = setup.port.parse().unwrap_or(5432);
state
.session
.set_connection_state(ConnectionState::Connecting);
state.session.set_metadata_state(MetadataState::Loading);
Some(vec![Effect::SaveAndConnect {
id: setup.editing_id.clone(),
name: setup.name.clone(),
host: setup.host.clone(),
port,
database: setup.database.clone(),
user: setup.user.clone(),
password: setup.password.clone(),
ssl_mode: setup.ssl_mode,
}])
} else {
Some(vec![])
}
}
Action::ConnectionSetupCancel => {
if state.connection_setup.is_first_run {
state.confirm_dialog.open(
"Confirm",
"No connection configured.\nAre you sure you want to quit?",
crate::app::confirm_dialog_state::ConfirmIntent::QuitNoConnection,
);
state.modal.push_mode(InputMode::ConfirmDialog);
Some(vec![])
} else {
state.modal.set_mode(InputMode::Normal);
Some(vec![Effect::DispatchActions(vec![Action::TryConnect])])
}
}
Action::ConnectionSaveCompleted(ConnectionTarget { id, dsn, name }) => {
state.connection_setup.is_first_run = false;
state.modal.set_mode(InputMode::Normal);
state.session.active_connection_id = Some(id.clone());
state.session.active_connection_name = Some(name.clone());
state.session.read_only = false;
state.session.begin_connecting(dsn);
Some(vec![Effect::FetchMetadata { dsn: dsn.clone() }])
}
Action::ConnectionSaveFailed(msg) => {
state
.session
.set_connection_state(ConnectionState::NotConnected);
state.session.set_metadata_state(MetadataState::NotLoaded);
state.messages.set_error_at(msg.clone(), now);
Some(vec![])
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::connection::ConnectionProfile;
fn create_profile(name: &str) -> ConnectionProfile {
ConnectionProfile::new(
name.to_string(),
"localhost".to_string(),
5432,
"db".to_string(),
"user".to_string(),
"pass".to_string(),
Default::default(),
)
.unwrap()
}
mod paste {
use super::*;
use crate::app::connection_setup_state::ConnectionField;
fn setup_state_with_field(field: ConnectionField) -> AppState {
let mut state = AppState::new("test".to_string());
state.modal.set_mode(InputMode::ConnectionSetup);
state.connection_setup.focused_field = field;
state.connection_setup.cursor_position = 0;
state.connection_setup.host.clear();
state.connection_setup.port.clear();
state.connection_setup.database.clear();
state.connection_setup.user.clear();
state.connection_setup.name.clear();
state.connection_setup.password.clear();
state
}
#[test]
fn paste_into_host_inserts_text() {
let mut state = setup_state_with_field(ConnectionField::Host);
reduce(
&mut state,
&Action::Paste("db.example.com".to_string()),
Instant::now(),
);
assert_eq!(state.connection_setup.host, "db.example.com");
}
#[test]
fn paste_into_port_filters_non_digits() {
let mut state = setup_state_with_field(ConnectionField::Port);
reduce(
&mut state,
&Action::Paste("54ab32".to_string()),
Instant::now(),
);
assert_eq!(state.connection_setup.port, "5432");
}
#[test]
fn paste_into_port_respects_limit() {
let mut state = setup_state_with_field(ConnectionField::Port);
state.connection_setup.port = "54".to_string();
state.connection_setup.cursor_position = 2;
reduce(
&mut state,
&Action::Paste("321000".to_string()),
Instant::now(),
);
assert_eq!(state.connection_setup.port, "54321");
}
#[test]
fn paste_into_full_port_does_nothing() {
let mut state = setup_state_with_field(ConnectionField::Port);
state.connection_setup.port = "12345".to_string();
state.connection_setup.cursor_position = 5;
reduce(&mut state, &Action::Paste("6".to_string()), Instant::now());
assert_eq!(state.connection_setup.port, "12345");
}
#[test]
fn paste_strips_newlines() {
let mut state = setup_state_with_field(ConnectionField::Host);
reduce(
&mut state,
&Action::Paste("local\nhost".to_string()),
Instant::now(),
);
assert_eq!(state.connection_setup.host, "localhost");
}
#[test]
fn paste_into_ssl_mode_ignored() {
let mut state = setup_state_with_field(ConnectionField::SslMode);
let ssl_mode_before = state.connection_setup.ssl_mode;
reduce(
&mut state,
&Action::Paste("disable".to_string()),
Instant::now(),
);
assert_eq!(state.connection_setup.ssl_mode, ssl_mode_before);
}
#[test]
fn paste_updates_cursor_and_viewport() {
let mut state = setup_state_with_field(ConnectionField::Host);
reduce(
&mut state,
&Action::Paste("db.example.com".to_string()),
Instant::now(),
);
assert_eq!(state.connection_setup.cursor_position, 14);
}
}
mod connection_save {
use super::*;
use crate::app::action::ConnectionTarget;
use crate::app::connection_state::ConnectionState;
use crate::domain::MetadataState;
fn fill_valid_form(state: &mut AppState) {
state.connection_setup.name = "test".to_string();
state.connection_setup.host = "localhost".to_string();
state.connection_setup.port = "5432".to_string();
state.connection_setup.database = "db".to_string();
state.connection_setup.user = "user".to_string();
state.connection_setup.password = "pass".to_string();
}
#[test]
fn save_sets_connection_and_metadata_state_as_pair() {
let mut state = AppState::new("test".to_string());
fill_valid_form(&mut state);
reduce(&mut state, &Action::ConnectionSetupSave, Instant::now());
assert_eq!(
state.session.connection_state(),
ConnectionState::Connecting
);
assert_eq!(state.session.metadata_state(), &MetadataState::Loading);
}
#[test]
fn save_completed_resets_read_only() {
let mut state = AppState::new("test".to_string());
state.session.read_only = true;
let action = Action::ConnectionSaveCompleted(ConnectionTarget {
id: crate::domain::ConnectionId::new(),
dsn: "postgres://localhost/new_db".to_string(),
name: "new_db".to_string(),
});
reduce(&mut state, &action, Instant::now());
assert!(!state.session.read_only);
}
}
mod open_connection_setup {
use super::*;
#[test]
fn is_first_run_true_when_no_connections() {
let mut state = AppState::new("test".to_string());
reduce(&mut state, &Action::OpenConnectionSetup, Instant::now());
assert!(state.connection_setup.is_first_run);
}
#[test]
fn is_first_run_false_when_connections_exist() {
let mut state = AppState::new("test".to_string());
let profile = create_profile("test");
state.set_connections(vec![profile]);
reduce(&mut state, &Action::OpenConnectionSetup, Instant::now());
assert!(!state.connection_setup.is_first_run);
}
#[test]
fn is_first_run_false_when_already_connected() {
let mut state = AppState::new("test".to_string());
state.session.dsn = Some("postgres://localhost/db".to_string());
reduce(&mut state, &Action::OpenConnectionSetup, Instant::now());
assert!(!state.connection_setup.is_first_run);
}
}
}