kptui 0.14.0

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

use crate::app::{App, Screen};
use crate::db::{calculate_warnings, delete_entry, save_database};

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;
	}

	if app.confirm_delete {
		handle_delete_confirmation(app, key.code);
		return;
	}

	match crate::input::normalize_shortcut(key.code) {
		KeyCode::Esc => request_close_edit(app),

		KeyCode::Enter => start_editing_field(app),

		KeyCode::Char('v') => {
			app.reveal_password = !app.reveal_password;
		}

		KeyCode::Char('d') => start_delete_confirmation(app),

		KeyCode::Down => move_selection(app, 1),
		KeyCode::Up => move_selection(app, -1),

		_ => {}
	}
}

fn handle_field_input(app: &mut App, key: KeyEvent) {
	let selected = app.edit_state.selected().unwrap_or(0);

	if selected == NOTES_INDEX {
		match key.code {
			KeyCode::Esc => {
				app.editing_field = false;
				app.notes_textarea = None;
			}

			KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
				if let Some(textarea) = app.notes_textarea.as_mut() {
					textarea.insert_newline();
				}
			}

			KeyCode::Enter => {
				commit_field(app);
			}

			_ => {
				if let Some(textarea) = app.notes_textarea.as_mut() {
					textarea.input(key);
				}
			}
		}

		return;
	}

	match key.code {
		KeyCode::Esc => {
			app.editing_field = false;
			app.field_buffer.clear();
			app.field_textarea = None;
			app.notes_textarea = None;
		}

		KeyCode::Enter => commit_field(app),

		_ => {
			if let Some(textarea) = app.field_textarea.as_mut() {
				textarea.input(key);
			}
		}
	}
}

fn new_field_textarea(value: &str) -> TextArea<'static> {
	TextArea::new(vec![value.to_owned()])
}

fn new_notes_textarea(notes: &str) -> TextArea<'static> {
	let mut textarea = TextArea::new(notes.split('\n').map(str::to_owned).collect());
	textarea.set_wrap_mode(WrapMode::WordOrGlyph);
	textarea
}

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_textarea = Some(new_field_textarea(&entry.name));
		}

		USER_INDEX => {
			app.field_textarea = Some(new_field_textarea(&entry.user));
		}

		PASSWORD_INDEX => {
			app.field_textarea = Some(new_field_textarea(&entry.password));
			app.reveal_password = true;
		}

		URL_INDEX => {
			app.field_textarea = Some(new_field_textarea(&entry.url));
		}

		TOTP_INDEX => {
			app.field_textarea = Some(new_field_textarea(&entry.totp));
		}

		NOTES_INDEX => {
			app.notes_textarea = Some(new_notes_textarea(&entry.notes));
		}

		_ => return,
	}

	app.field_buffer.clear();
	app.editing_field = true;
	app.status = None;
}

fn commit_field(app: &mut App) {
	let selected = app.edit_state.selected().unwrap_or(0);
	let value = if selected == NOTES_INDEX {
		app.notes_textarea.as_ref().map(|textarea| textarea.lines().join("\n")).unwrap_or_default()
	} else {
		app.field_textarea.as_ref().map(|textarea| textarea.lines().join("\n")).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;
	app.field_buffer.clear();
	app.field_textarea = None;
	app.notes_textarea = None;
}

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 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_buffer.clear();
	app.confirm_delete = false;
	app.confirm_exit = false;
	app.reveal_password = false;
	app.edit_state.select(None);
	app.screen = Screen::Index;
}

fn save_edit(app: &mut App) {
	let Some(entry) = app.edit_entry.take() else {
		return;
	};

	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)
			}
		}
	};

	calculate_warnings(&mut app.entries);
	app.refresh_filter();

	if let Some(idx) = saved_idx {
		persist_entry(app, 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:#}")),
	}
}

fn start_delete_confirmation(app: &mut App) {
	if app.edit_entry.is_none() {
		return;
	}

	app.confirm_delete = true;
	app.status = Some("Delete this entry? [y] confirm  [any other key] cancel".to_string());
}

fn handle_delete_confirmation(app: &mut App, key: KeyCode) {
	app.confirm_delete = false;
	app.status = None;

	if let KeyCode::Char('y') = crate::input::normalize_shortcut(key) {
		delete_current_entry(app);
	}
}

fn delete_current_entry(app: &mut App) {
	let Some(entry) = app.edit_entry.take() else {
		reset_edit_state(app);
		return;
	};

	if let Some(id) = entry.id {
		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 deleted: database is locked".to_string());
			app.edit_entry = Some(entry);
			return;
		};

		if let Err(err) = delete_entry(path, key, db, id) {
			app.status = Some(format!("Failed to delete: {err:#}"));
			app.edit_entry = Some(entry);
			return;
		}
	}

	if let Some(idx) = app.edit_target
		&& idx < app.entries.len()
	{
		app.entries.remove(idx);
	}

	calculate_warnings(&mut app.entries);
	app.refresh_filter();

	reset_edit_state(app);
	app.status = Some("Entry deleted".to_string());
}