kptui 0.18.0

TUI password manager for KeePass vaults
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};

use crate::app::{App, Screen};
use crate::db::save_database;
use crate::input::command::preview_entry;
use crate::input::fieldedit::{EditAction, FieldEditor};

const FIELD_COUNT: usize = 7;
const NAME_INDEX: usize = 0;
const USER_INDEX: usize = 1;
const PASSWORD_INDEX: usize = 2;
const URL_INDEX: usize = 3;
const TOTP_INDEX: usize = 4;
const LAST_MODIFIED_INDEX: usize = 5;
const NOTES_INDEX: usize = 6;

pub fn handle_edit_input(app: &mut App, key: KeyEvent) {
	if app.editing_field {
		handle_field_input(app, key);
		return;
	}

	if app.confirm_exit {
		handle_exit_confirmation(app, key.code);
		return;
	}

	match key.code {
		KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
			save_and_reopen_edit(app);
		}
		_ => {
			app.status = None;
			match key.code {
				KeyCode::Esc => request_close_edit(app),
				KeyCode::Enter => start_editing_field(app),
				KeyCode::PageUp => {
					app.field_editor_scroll = app.field_editor_scroll.saturating_sub(1);
				}
				KeyCode::PageDown => {
					app.field_editor_scroll = app.field_editor_scroll.saturating_add(1);
				}
				KeyCode::Down => move_selection(app, 1),
				KeyCode::Up => move_selection(app, -1),
				_ => {}
			}
		}
	}
}

fn move_selection(app: &mut App, delta: i32) {
	let current = app.edit_state.selected().unwrap_or(0) as i32;
	let next = (current + delta).rem_euclid(FIELD_COUNT as i32) as usize;
	app.edit_state.select(Some(next));
}

fn start_editing_field(app: &mut App) {
	let selected = app.edit_state.selected().unwrap_or(0);
	if selected == LAST_MODIFIED_INDEX {
		app.status = Some("Last modified is set automatically".to_string());
		return;
	}

	let Some(entry) = app.edit_entry.as_ref() else {
		return;
	};

	match selected {
		NAME_INDEX => {
			app.field_editor = Some(FieldEditor::with_text(&entry.name));
		}

		USER_INDEX => {
			app.field_editor = Some(FieldEditor::with_text(&entry.user));
		}

		PASSWORD_INDEX => {
			app.field_editor = Some(FieldEditor::with_text(&entry.password));
		}

		URL_INDEX => {
			app.field_editor = Some(FieldEditor::with_text(&entry.url));
		}

		TOTP_INDEX => {
			app.field_editor = Some(FieldEditor::with_text(&entry.totp));
		}

		NOTES_INDEX => {
			app.field_editor = Some(FieldEditor::with_multiline_text(&entry.notes));
		}

		_ => return,
	}

	app.editing_field = true;
	app.status = None;
}

fn handle_field_input(app: &mut App, key: KeyEvent) {
	let action = match app.field_editor.as_mut() {
		Some(editor) => editor.handle_key(key),
		None => {
			app.editing_field = false;
			return;
		}
	};

	match action {
		EditAction::Continue => {}

		EditAction::Accept => commit_field(app),

		EditAction::Cancel => {
			app.editing_field = false;
			app.field_editor = None;
		}
	}
}

fn commit_field(app: &mut App) {
	let selected = app.edit_state.selected().unwrap_or(0);

	let value = app.field_editor.take().map(|editor| editor.into_text()).unwrap_or_default();

	if let Some(entry) = app.edit_entry.as_mut() {
		match selected {
			NAME_INDEX => entry.name = value,
			USER_INDEX => entry.user = value,
			PASSWORD_INDEX => entry.password = value,
			URL_INDEX => entry.url = value,
			TOTP_INDEX => entry.totp = value,
			NOTES_INDEX => entry.notes = value,
			LAST_MODIFIED_INDEX => {}
			_ => {}
		}
	}
	app.editing_field = false;
}

fn request_close_edit(app: &mut App) {
	if has_unsaved_changes(app) {
		app.confirm_exit = true;
		app.status = Some("Save changes? [y] Yes  [n] No".to_string());
	} else {
		reset_edit_state(app);
	}
}

fn has_unsaved_changes(app: &App) -> bool {
	let (Some(entry), Some(original)) = (app.edit_entry.as_ref(), app.edit_original.as_ref()) else {
		return false;
	};

	entry.name != original.name
		|| entry.user != original.user
		|| entry.password != original.password
		|| entry.url != original.url
		|| entry.totp != original.totp
		|| entry.notes != original.notes
}

fn handle_exit_confirmation(app: &mut App, key: KeyCode) {
	match crate::input::normalize_shortcut(key) {
		KeyCode::Char('y') => {
			app.confirm_exit = false;
			close_edit(app);
		}
		KeyCode::Char('n') => {
			app.confirm_exit = false;
			reset_edit_state(app);
			app.status = Some("Changes discarded".to_string());
		}
		KeyCode::Esc => {
			app.confirm_exit = false;
			app.status = None;
		}
		_ => {}
	}
}

fn save_and_reopen_edit(app: &mut App) {
	let Some(saved_idx) = save_edit(app) else {
		reset_edit_state(app);
		return;
	};

	reset_edit_state(app);
	let Some(filtered_pos) = app.filtered.iter().position(|&idx| idx == saved_idx) else {
		return;
	};

	app.index_state.select(Some(filtered_pos));
	preview_entry(app);
}

fn close_edit(app: &mut App) {
	save_edit(app);
	reset_edit_state(app);
}

fn reset_edit_state(app: &mut App) {
	app.edit_entry = None;
	app.edit_original = None;
	app.edit_target = None;
	app.editing_field = false;
	app.field_editor = None;
	app.field_editor_scroll = 0;
	app.confirm_exit = false;
	app.edit_state.select(None);
	app.screen = Screen::Index;
}

fn save_edit(app: &mut App) -> Option<usize> {
	let entry = app.edit_entry.take()?;
	let saved_idx = match app.edit_target {
		Some(idx) => {
			if let Some(slot) = app.entries.get_mut(idx) {
				*slot = entry;
			}
			Some(idx)
		}
		None => {
			if entry.name.trim().is_empty() {
				None
			} else {
				app.entries.push(entry);
				Some(app.entries.len() - 1)
			}
		}
	};
	app.refresh_filter();
	if let Some(idx) = saved_idx {
		persist_entry(app, idx);
	}
	saved_idx
}

fn persist_entry(app: &mut App, idx: usize) {
	let (Some(db), Some(key), Some(path)) = (app.kdbx.as_mut(), app.db_key.as_ref(), app.config.default_database.as_ref()) else {
		app.status = Some("Not saved: database is locked".to_string());
		return;
	};

	let Some(target) = app.entries.get_mut(idx) else {
		return;
	};

	match save_database(path, key, db, std::slice::from_mut(target)) {
		Ok(()) => app.status = Some("Saved".to_string()),
		Err(err) => app.status = Some(format!("Failed to save: {err:#}")),
	}
}