use crate::config::{EnvProfile, VarKind};
use crate::db;
use crate::profile_editor::{confirm_dialog, edit_profile_name_dialog};
use crate::tui::state::{AppState, Holding, InputMode, PlacePartOutcome, RenameOutcome};
use crate::tui::view::View;
use crate::tui::{commands, dialogs, editor, select};
use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::backend::Backend;
use ratatui::Terminal;
pub fn handle_key_event<B: Backend>(
terminal: &mut Terminal<B>,
app: &mut AppState,
key: KeyEvent,
) -> Result<bool> {
if key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
{
return Ok(true);
}
match app.input_mode {
InputMode::Normal => handle_normal_key(terminal, app, key.code),
InputMode::Command => handle_command_key(terminal, app, key),
InputMode::Search => handle_search_key(app, key),
}
}
fn cycle_view(app: &mut AppState) {
app.active_view = match app.active_view {
View::Profiles => View::Vars,
View::Vars => View::Parts,
View::Parts => View::Items,
View::Items => View::Defs,
View::Defs => View::Preview,
View::Preview => View::Export,
View::Export => View::Help,
View::Help => View::Profiles,
};
}
fn handle_normal_key<B: Backend>(
terminal: &mut Terminal<B>,
app: &mut AppState,
code: KeyCode,
) -> Result<bool> {
match code {
KeyCode::Char('q') => return Ok(true),
KeyCode::Char('j') => move_selection(app, 1),
KeyCode::Char('k') => move_selection(app, -1),
KeyCode::Char('G') | KeyCode::Home => jump_to_top(app),
KeyCode::Char('g') | KeyCode::End => jump_to_bottom(app),
KeyCode::PageUp => move_selection(app, -10),
KeyCode::PageDown => move_selection(app, 10),
KeyCode::Esc => {
match app.holding.take() {
Some(Holding::Part { .. }) => app.status = "cancelled move".to_string(),
Some(Holding::Item(_)) => app.status = "cancelled pick".to_string(),
None => app.status.clear(),
}
}
KeyCode::Tab => cycle_view(app),
KeyCode::Char('?') => app.active_view = View::Help,
KeyCode::Char(':') => {
app.input_mode = InputMode::Command;
app.command_input.clear();
app.command_selected = 0;
commands::refresh_command_suggestions(app);
}
KeyCode::Char('/') => {
if app.active_view.is_filterable() {
app.input_mode = InputMode::Search;
app.search_target = app.active_view;
app.command_input.clear();
}
}
KeyCode::Up => move_selection(app, -1),
KeyCode::Down => move_selection(app, 1),
KeyCode::Enter => activate_selection(app),
KeyCode::Char('A') if app.active_view == View::Profiles => {
if let Some(new_name) = edit_profile_name_dialog(terminal, None)? {
let new_profile = EnvProfile::new(&new_name);
if app.add_profile(new_profile)? {
app.active_profile_index = app.profiles.len().saturating_sub(1);
app.status = format!("added profile: {new_name}");
} else {
app.status = format!("profile already exists: {new_name}");
}
}
}
KeyCode::Char('E') if app.active_view == View::Profiles => {
if let Some(i) = select::selected_profile_index(app) {
let current_name = app.profiles[i].name.clone();
if let Some(new_name) = edit_profile_name_dialog(terminal, Some(¤t_name))? {
match app.update_profile(i, new_name.clone()) {
Ok(RenameOutcome::Renamed) => {
app.status = format!("renamed profile: {current_name} -> {new_name}");
}
Ok(RenameOutcome::Unchanged) => {
app.status = "name unchanged".to_string();
}
Ok(RenameOutcome::Rejected) => {
app.status = format!("profile already exists: {new_name}");
}
Err(e) => {
app.status = format!("rename failed: {e}");
}
}
}
}
}
KeyCode::Char('D') if app.active_view == View::Profiles => {
if let Some(i) = select::selected_profile_index(app) {
if confirm_dialog(terminal, "Delete profile?")? {
let name = app.profiles[i].name.clone();
if app.delete_profile(i)? {
app.status = format!("deleted profile: {name}");
} else {
app.status = "cannot delete the last profile".to_string();
}
}
}
}
KeyCode::Char('C') if app.active_view == View::Defs => {
if let Some(def) = dialogs::create_custom_var_dialog(terminal)? {
db::save_custom_var_def(&app.conn, &def)?;
app.refresh_var_options()?;
app.status = format!("saved var def: {}", def.name);
}
}
KeyCode::Char('a') if app.active_view == View::Items => {
if let Some(mut item) = dialogs::create_or_edit_item_dialog(terminal, None)? {
db::save_item(&app.conn, &mut item)?;
app.refresh_items()?;
app.status = format!("saved item: {}", item.value);
}
}
KeyCode::Char('e') if app.active_view == View::Items => {
if let Some(i) = select::selected_item_index(app) {
if let Some(initial) = app.items.get(i).cloned() {
if let Some(mut edited) =
dialogs::create_or_edit_item_dialog(terminal, Some(&initial))?
{
db::save_item(&app.conn, &mut edited)?;
app.refresh_items()?;
app.status = format!("updated item: {}", edited.value);
}
}
}
}
KeyCode::Char('d') if app.active_view == View::Items => {
if let Some(i) = select::selected_item_index(app) {
if let Some(id) = app.items.get(i).and_then(|it| it.id) {
if confirm_dialog(terminal, "Delete item?")? {
db::delete_item(&app.conn, id)?;
app.refresh_items()?;
app.status = "deleted item".to_string();
}
}
}
}
KeyCode::Char('y') if app.active_view == View::Items => {
if let Some(i) = select::selected_item_index(app) {
if let Some(orig) = app.items.get(i).cloned() {
let mut dup = orig.clone();
dup.id = None;
db::save_item(&app.conn, &mut dup)?;
app.refresh_items()?;
app.status = "duplicated item".to_string();
}
}
}
KeyCode::Char('m') if app.active_view == View::Items => {
if let Some(i) = select::selected_item_index(app) {
if let Some(it) = app.items.get(i).cloned() {
app.holding = Some(Holding::Item(it));
app.status = "picked item".to_string();
}
}
}
KeyCode::Char('p') if app.active_view == View::Items => {
let var = app
.selected_var_name()
.unwrap_or_else(|| "PATH".to_string());
let opt = select::var_option_for(app, &var);
if opt.kind != VarKind::List {
app.status = "cannot drop into scalar var".to_string();
} else if let Some(i) = select::selected_item_index(app) {
if let Some(it) = app.items.get(i).cloned() {
if let Some(e) = select::make_part_entry(app, &var, it.value) {
app.add_env_var(e)?;
app.status = format!("dropped into {var}");
}
}
}
}
KeyCode::Char('p') if app.active_view == View::Vars => {
if let Some(holding) = app.holding.clone() {
let rows = select::compute_var_rows(app);
if let Some(i) = app.vars_list_state.selected() {
if let Some(row) = rows.get(i) {
if row.kind != VarKind::List {
app.status = "cannot drop into scalar var".to_string();
} else {
match holding {
Holding::Item(it) => {
if let Some(e) =
select::make_part_entry(app, &row.name, it.value.clone())
{
app.add_env_var(e)?;
app.holding = None;
app.status = format!("dropped into {}", row.name);
}
}
Holding::Part { .. } => {
let insert_at = select::current_var_parts(app, &row.name).len();
match app.place_held_part(&row.name, insert_at)? {
PlacePartOutcome::Moved => {
app.status = format!("moved part into {}", row.name);
}
PlacePartOutcome::OriginGone => {
app.status =
"held part no longer exists; cancelled move"
.to_string();
}
PlacePartOutcome::CannotConvert => {
app.status = "cannot drop into target var".to_string();
}
PlacePartOutcome::NotHolding => {}
}
}
}
}
}
}
}
}
KeyCode::Char('a') if app.active_view == View::Parts => {
let var = app
.selected_var_name()
.unwrap_or_else(|| "PATH".to_string());
let opt = select::var_option_for(app, &var);
if let Some(new_entry) =
editor::edit_env_var_dialog(terminal, std::slice::from_ref(&opt), None)?
{
if opt.kind == VarKind::Scalar {
app.replace_var_parts(&var, vec![new_entry])?;
} else {
app.add_env_var(new_entry)?;
}
app.status = format!("added part to {var}");
}
}
KeyCode::Char('e') if app.active_view == View::Parts => {
let var = app
.selected_var_name()
.unwrap_or_else(|| "PATH".to_string());
let opt = select::var_option_for(app, &var);
let mut parts = select::current_var_parts(app, &var);
let visible = select::visible_part_indices(app, &parts);
if let Some(sel) = app.parts_list_state.selected() {
if let Some(part_i) = visible.get(sel).copied() {
if let Some(initial) = parts.get(part_i).cloned() {
if let Some(new_entry) = editor::edit_env_var_dialog(
terminal,
std::slice::from_ref(&opt),
Some(&initial),
)? {
parts[part_i] = new_entry;
app.replace_var_parts(&var, parts)?;
app.status = format!("edited part in {var}");
}
}
}
}
}
KeyCode::Char('d') if app.active_view == View::Parts => {
let var = app
.selected_var_name()
.unwrap_or_else(|| "PATH".to_string());
let mut parts = select::current_var_parts(app, &var);
let visible = select::visible_part_indices(app, &parts);
if let Some(sel) = app.parts_list_state.selected() {
if let Some(part_i) = visible.get(sel).copied() {
parts.remove(part_i);
app.replace_var_parts(&var, parts)?;
app.status = format!("deleted part from {var}");
}
}
}
KeyCode::Char('y') if app.active_view == View::Parts => {
let var = app
.selected_var_name()
.unwrap_or_else(|| "PATH".to_string());
let opt = select::var_option_for(app, &var);
if opt.kind != VarKind::List {
app.status = "cannot duplicate part in scalar var".to_string();
} else {
let mut parts = select::current_var_parts(app, &var);
let visible = select::visible_part_indices(app, &parts);
if let Some(sel) = app.parts_list_state.selected() {
if let Some(part_i) = visible.get(sel).copied() {
let dup = parts[part_i].clone();
parts.insert(part_i + 1, dup);
app.replace_var_parts(&var, parts)?;
app.status = format!("duplicated part in {var}");
}
}
}
}
KeyCode::Char('K') if app.active_view == View::Parts => {
if !app.parts_filter.is_empty() {
app.status = "clear filter to reorder".to_string();
} else {
let var = app
.selected_var_name()
.unwrap_or_else(|| "PATH".to_string());
let mut parts = select::current_var_parts(app, &var);
if let Some(sel) = app.parts_list_state.selected() {
if sel > 0 && sel < parts.len() {
parts.swap(sel - 1, sel);
app.replace_var_parts(&var, parts)?;
app.parts_list_state.select(Some(sel - 1));
app.status = format!("moved part up in {var}");
}
}
}
}
KeyCode::Char('J') if app.active_view == View::Parts => {
if !app.parts_filter.is_empty() {
app.status = "clear filter to reorder".to_string();
} else {
let var = app
.selected_var_name()
.unwrap_or_else(|| "PATH".to_string());
let mut parts = select::current_var_parts(app, &var);
if let Some(sel) = app.parts_list_state.selected() {
if sel + 1 < parts.len() {
parts.swap(sel, sel + 1);
app.replace_var_parts(&var, parts)?;
app.parts_list_state.select(Some(sel + 1));
app.status = format!("moved part down in {var}");
}
}
}
}
KeyCode::Char('m') if app.active_view == View::Parts => {
let var = app
.selected_var_name()
.unwrap_or_else(|| "PATH".to_string());
let parts = select::current_var_parts(app, &var);
let visible = select::visible_part_indices(app, &parts);
if let Some(sel) = app.parts_list_state.selected() {
if let Some(part_i) = visible.get(sel).copied() {
if app.hold_part(&var, part_i) {
app.status = "moving part (navigate, p:drop, Esc:cancel)".to_string();
}
}
}
}
KeyCode::Char('p') if app.active_view == View::Parts => {
let var = app
.selected_var_name()
.unwrap_or_else(|| "PATH".to_string());
let opt = select::var_option_for(app, &var);
if opt.kind != VarKind::List {
app.status = "cannot drop into scalar var".to_string();
} else if let Some(holding) = app.holding.clone() {
let mut parts = select::current_var_parts(app, &var);
let visible = select::visible_part_indices(app, &parts);
let insert_at = app
.parts_list_state
.selected()
.and_then(|sel| visible.get(sel).copied())
.unwrap_or(parts.len());
match holding {
Holding::Item(it) => {
if let Some(e) = select::make_part_entry(app, &var, it.value.clone()) {
parts.insert(insert_at, e);
app.replace_var_parts(&var, parts)?;
app.holding = None;
app.status = format!("dropped into {var}");
}
}
Holding::Part { .. } => {
match app.place_held_part(&var, insert_at)? {
PlacePartOutcome::Moved => {
app.status = format!("moved part into {var}");
}
PlacePartOutcome::OriginGone => {
app.status =
"held part no longer exists; cancelled move".to_string();
}
PlacePartOutcome::CannotConvert => {
app.status = "cannot drop into target var".to_string();
}
PlacePartOutcome::NotHolding => {}
}
}
}
}
}
_ => {}
}
Ok(false)
}
fn move_selection(app: &mut AppState, delta: isize) {
let len = {
let a: &AppState = &*app;
match a.active_view {
View::Profiles => select::visible_profile_indices(a).len(),
View::Vars => select::compute_var_rows(a).len(),
View::Parts => {
let var = a.selected_var_name().unwrap_or_else(|| "PATH".to_string());
let parts = select::current_var_parts(a, &var);
select::visible_part_indices(a, &parts).len()
}
View::Items => select::visible_item_indices(a).len(),
View::Defs => {
let mut defs = a.var_options.clone();
defs.sort_by(|x, y| x.name.cmp(&y.name));
if !a.defs_filter.is_empty() {
let q = a.defs_filter.to_lowercase();
defs.retain(|d| d.name.to_lowercase().contains(&q));
}
defs.len()
}
View::Preview | View::Export | View::Help => 0,
}
};
let state = match app.active_view {
View::Profiles => Some(&mut app.profile_list_state),
View::Vars => Some(&mut app.vars_list_state),
View::Defs => Some(&mut app.defs_list_state),
View::Parts => Some(&mut app.parts_list_state),
View::Items => Some(&mut app.items_list_state),
View::Preview | View::Export | View::Help => None,
};
let Some(state) = state else {
return;
};
if len == 0 {
state.select(None);
return;
}
let cur = state.selected().unwrap_or(0).min(len - 1) as isize;
let next = (cur + delta).clamp(0, (len - 1) as isize) as usize;
state.select(Some(next));
}
fn activate_selection(app: &mut AppState) {
match app.active_view {
View::Profiles => {
if let Some(i) = select::selected_profile_index(app) {
app.active_profile_index = i;
app.status = format!("profile: {}", app.profiles[i].name);
}
}
View::Vars => {
let rows = select::compute_var_rows(app);
if let Some(i) = app.vars_list_state.selected() {
if let Some(row) = rows.get(i) {
app.active_view = View::Parts;
app.status = format!("selected var: {}", row.name);
}
}
}
_ => {}
}
}
fn handle_command_key<B: Backend>(
terminal: &mut Terminal<B>,
app: &mut AppState,
key: KeyEvent,
) -> Result<bool> {
match key.code {
KeyCode::Esc => {
app.input_mode = InputMode::Normal;
}
KeyCode::Enter => {
let exec = commands::pick_command_to_execute(app);
let quit = commands::execute_command(terminal, app, &exec)?;
app.input_mode = InputMode::Normal;
if quit {
return Ok(true);
}
}
KeyCode::Tab => {
let idx =
commands::clamp_selection(app.command_selected, app.command_suggestions.len());
if let Some(s) = app.command_suggestions.get(idx).cloned() {
app.command_input = s;
commands::refresh_command_suggestions(app);
}
}
KeyCode::Up => {
app.command_selected = commands::clamp_selection(
app.command_selected.saturating_sub(1),
app.command_suggestions.len(),
);
}
KeyCode::Down => {
app.command_selected =
commands::clamp_selection(app.command_selected + 1, app.command_suggestions.len());
}
KeyCode::Backspace => {
app.command_input.pop();
commands::refresh_command_suggestions(app);
}
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
app.command_input.push(c);
commands::refresh_command_suggestions(app);
}
_ => {}
}
Ok(false)
}
fn handle_search_key(app: &mut AppState, key: KeyEvent) -> Result<bool> {
match key.code {
KeyCode::Esc => {
app.command_input.clear();
apply_live_filter(app, "");
app.input_mode = InputMode::Normal;
}
KeyCode::Enter => {
app.command_input.clear();
app.input_mode = InputMode::Normal;
}
KeyCode::Backspace => {
app.command_input.pop();
let q = app.command_input.clone();
apply_live_filter(app, &q);
}
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
app.command_input.push(c);
let q = app.command_input.clone();
apply_live_filter(app, &q);
}
_ => {}
}
Ok(false)
}
fn apply_live_filter(app: &mut AppState, q: &str) {
let q = q.to_string();
match app.search_target {
View::Profiles => app.profiles_filter = q,
View::Vars => app.vars_filter = q,
View::Defs => app.defs_filter = q,
View::Parts => app.parts_filter = q,
View::Items => app.items_filter = q,
View::Preview | View::Export | View::Help => {}
}
}
fn jump_to_top(app: &mut AppState) {
let state = match app.active_view {
View::Profiles => Some(&mut app.profile_list_state),
View::Vars => Some(&mut app.vars_list_state),
View::Defs => Some(&mut app.defs_list_state),
View::Parts => Some(&mut app.parts_list_state),
View::Items => Some(&mut app.items_list_state),
View::Preview | View::Export | View::Help => None,
};
if let Some(state) = state {
state.select(Some(0));
}
}
fn jump_to_bottom(app: &mut AppState) {
let (len, state) = match app.active_view {
View::Profiles => {
let len = select::visible_profile_indices(app).len();
(len, Some(&mut app.profile_list_state))
}
View::Vars => {
let len = select::compute_var_rows(app).len();
(len, Some(&mut app.vars_list_state))
}
View::Parts => {
let var = app
.selected_var_name()
.unwrap_or_else(|| "PATH".to_string());
let parts = select::current_var_parts(app, &var);
let len = select::visible_part_indices(app, &parts).len();
(len, Some(&mut app.parts_list_state))
}
View::Items => {
let len = select::visible_item_indices(app).len();
(len, Some(&mut app.items_list_state))
}
View::Defs => {
let mut defs = app.var_options.clone();
defs.sort_by(|a, b| a.name.cmp(&b.name));
if !app.defs_filter.is_empty() {
let q = app.defs_filter.to_lowercase();
defs.retain(|d| d.name.to_lowercase().contains(&q));
}
(defs.len(), Some(&mut app.defs_list_state))
}
View::Preview | View::Export | View::Help => (0, None),
};
if let Some(state) = state {
if len > 0 {
state.select(Some(len - 1));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::EnvProfile;
use crate::tui::state::{builtin_var_options, InputMode};
use crate::tui::theme::BathConfig;
use crate::tui::view::View;
use crossterm::event::KeyModifiers;
use ratatui::backend::TestBackend;
use ratatui::widgets::ListState;
use rusqlite::Connection;
fn test_app() -> AppState {
let conn = Connection::open_in_memory().unwrap();
db::initialize_db(&conn).unwrap();
let default = EnvProfile::new("default");
db::save_profile(&conn, &default).unwrap();
let mut app = AppState {
conn,
profiles: vec![default],
active_profile_index: 0,
profile_list_state: ListState::default(),
custom_var_defs: Vec::new(),
var_options: builtin_var_options(),
active_view: View::Vars,
input_mode: InputMode::Normal,
theme_preset: crate::tui::theme::default_preset().to_string(),
theme: crate::tui::theme::resolve_theme(crate::tui::theme::default_preset(), None)
.unwrap(),
config: BathConfig::default(),
vars_list_state: ListState::default(),
defs_list_state: ListState::default(),
parts_list_state: ListState::default(),
items_list_state: ListState::default(),
profiles_filter: String::new(),
vars_filter: String::new(),
defs_filter: String::new(),
parts_filter: String::new(),
items_filter: String::new(),
command_input: String::new(),
command_suggestions: Vec::new(),
command_selected: 0,
search_target: View::Vars,
status: String::new(),
holding: None,
items: Vec::new(),
};
select_var_row(&mut app, "PATH");
app
}
fn select_var_row(app: &mut AppState, name: &str) {
let row = select::compute_var_rows(app)
.iter()
.position(|r| r.name == name)
.unwrap();
app.vars_list_state.select(Some(row));
}
fn test_terminal() -> Terminal<TestBackend> {
Terminal::new(TestBackend::new(80, 24)).unwrap()
}
#[test]
fn ctrl_c_quits_like_q() {
let mut terminal = test_terminal();
let mut app = test_app();
let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
let quit = handle_key_event(&mut terminal, &mut app, key).unwrap();
assert!(quit, "Ctrl+C must quit like q does");
}
#[test]
fn ctrl_modified_char_is_not_inserted_in_search_input() {
let mut terminal = test_terminal();
let mut app = test_app();
app.input_mode = InputMode::Search;
app.search_target = View::Vars;
let key = KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL);
handle_key_event(&mut terminal, &mut app, key).unwrap();
assert_eq!(
app.command_input, "",
"Ctrl-modified keys must not land in the search field as plain chars"
);
assert_eq!(app.vars_filter, "");
}
#[test]
fn ctrl_modified_char_is_not_inserted_in_command_input() {
let mut terminal = test_terminal();
let mut app = test_app();
app.input_mode = InputMode::Command;
let key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL);
handle_key_event(&mut terminal, &mut app, key).unwrap();
assert_eq!(
app.command_input, "",
"Ctrl-modified keys must not land in the command palette as plain chars"
);
}
#[test]
fn plain_and_shifted_chars_are_still_inserted_in_search_input() {
let mut terminal = test_terminal();
let mut app = test_app();
app.input_mode = InputMode::Search;
app.search_target = View::Vars;
let plain = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE);
handle_key_event(&mut terminal, &mut app, plain).unwrap();
let shifted = KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT);
handle_key_event(&mut terminal, &mut app, shifted).unwrap();
assert_eq!(app.command_input, "pA");
}
use crate::config::{Entry, PathEntry};
fn path_entry(path: &str, program: &str, version: &str) -> Entry {
Entry::Path(PathEntry {
path: path.to_string(),
program: program.to_string(),
version: version.to_string(),
})
}
fn press(terminal: &mut Terminal<TestBackend>, app: &mut AppState, c: char) -> bool {
let key = KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
handle_key_event(terminal, app, key).unwrap()
}
fn press_key(terminal: &mut Terminal<TestBackend>, app: &mut AppState, code: KeyCode) -> bool {
let key = KeyEvent::new(code, KeyModifiers::NONE);
handle_key_event(terminal, app, key).unwrap()
}
fn buffer_lines(terminal: &Terminal<TestBackend>) -> Vec<String> {
let buf = terminal.backend().buffer();
(0..buf.area.height)
.map(|y| {
(0..buf.area.width)
.map(|x| buf.get(x, y).symbol.clone())
.collect::<String>()
})
.collect()
}
fn draw(terminal: &mut Terminal<TestBackend>, app: &mut AppState) {
terminal
.draw(|f| crate::tui::ui::draw_main_ui(f, app))
.unwrap();
}
#[test]
fn palette_enter_executes_the_visibly_highlighted_suggestion() {
let mut terminal = test_terminal();
let mut app = test_app();
press(&mut terminal, &mut app, ':');
let total = app.command_suggestions.len();
assert!(
total > 8,
"test needs more suggestions than fit in the palette window, got {total}"
);
for _ in 0..total + 5 {
press_key(&mut terminal, &mut app, KeyCode::Down);
}
let exec = commands::pick_command_to_execute(&app);
draw(&mut terminal, &mut app);
let lines = buffer_lines(&terminal);
let highlighted: Vec<&String> = lines.iter().filter(|l| l.contains("» ")).collect();
assert_eq!(
highlighted.len(),
1,
"exactly one suggestion row must carry the highlight symbol, got {highlighted:?}"
);
assert!(
highlighted[0].contains(&exec),
"Enter must execute the visibly highlighted suggestion; highlighted row {:?} but Enter would run {:?}",
highlighted[0],
exec
);
}
#[test]
fn palette_prompt_renders_typed_command_text() {
let mut terminal = test_terminal();
let mut app = test_app();
press(&mut terminal, &mut app, ':');
for c in "xyz".chars() {
press(&mut terminal, &mut app, c);
}
draw(&mut terminal, &mut app);
let lines = buffer_lines(&terminal);
assert!(
lines.iter().any(|l| l.contains(":xyz")),
"the typed palette input must be visible in the prompt, got {lines:?}"
);
}
#[test]
fn header_command_hints_row_is_rendered() {
let mut terminal = test_terminal();
let mut app = test_app();
draw(&mut terminal, &mut app);
let lines = buffer_lines(&terminal);
assert!(
lines.iter().any(|l| l.contains("Commands: :profiles")),
"the command-hints header row must be visible, got {lines:?}"
);
}
#[test]
fn picking_part_with_m_defers_removal_so_quit_loses_nothing() {
let mut terminal = test_terminal();
let mut app = test_app();
app.add_env_var(path_entry("/opt/gcc/bin", "gcc", "13"))
.unwrap();
app.active_view = View::Parts;
app.parts_list_state.select(Some(0));
press(&mut terminal, &mut app, 'm');
assert!(
matches!(app.holding, Some(Holding::Part { .. })),
"m must pick the part"
);
assert_eq!(
app.profiles[0].entries.len(),
1,
"picking with m must not remove the part from the profile"
);
let loaded = db::load_profile(&app.conn, "default").unwrap();
assert_eq!(
loaded.entries.len(),
1,
"picking with m must not persist a removal; quitting while holding would lose the part"
);
let quit = press(&mut terminal, &mut app, 'q');
assert!(quit);
let loaded = db::load_profile(&app.conn, "default").unwrap();
assert_eq!(loaded.entries.len(), 1);
}
#[test]
fn moving_path_part_within_var_preserves_program_and_version() {
let mut terminal = test_terminal();
let mut app = test_app();
app.add_env_var(path_entry("/opt/gcc/bin", "gcc", "13"))
.unwrap();
app.add_env_var(path_entry("/a", "", "")).unwrap();
app.add_env_var(path_entry("/b", "", "")).unwrap();
app.active_view = View::Parts;
app.parts_list_state.select(Some(0));
press(&mut terminal, &mut app, 'm');
app.parts_list_state.select(Some(2));
press(&mut terminal, &mut app, 'p');
let parts = select::current_var_parts(&app, "PATH");
assert_eq!(parts.len(), 3);
let gcc = parts
.iter()
.find_map(|e| match e {
Entry::Path(pe) if pe.path == "/opt/gcc/bin" => Some(pe.clone()),
_ => None,
})
.expect("moved part must still exist");
assert_eq!(
(gcc.program.as_str(), gcc.version.as_str()),
("gcc", "13"),
"moving a part must not wipe PathEntry program/version metadata"
);
let paths: Vec<String> = parts
.iter()
.map(|e| match e {
Entry::Path(pe) => pe.path.clone(),
_ => unreachable!(),
})
.collect();
assert_eq!(paths, vec!["/a", "/opt/gcc/bin", "/b"]);
assert!(app.holding.is_none());
let loaded = db::load_profile(&app.conn, "default").unwrap();
assert!(loaded
.entries
.iter()
.any(|e| matches!(e, Entry::Path(pe) if pe.program == "gcc" && pe.version == "13")));
}
#[test]
fn held_marker_follows_the_held_entry_after_reorder() {
let mut terminal = test_terminal();
let mut app = test_app();
app.add_env_var(path_entry("/held", "gcc", "13")).unwrap();
app.add_env_var(path_entry("/a", "", "")).unwrap();
app.add_env_var(path_entry("/b", "", "")).unwrap();
app.active_view = View::Parts;
app.parts_list_state.select(Some(0));
press(&mut terminal, &mut app, 'm');
app.replace_var_parts(
"PATH",
vec![
path_entry("/a", "", ""),
path_entry("/b", "", ""),
path_entry("/held", "gcc", "13"),
],
)
.unwrap();
draw(&mut terminal, &mut app);
let lines = buffer_lines(&terminal);
let held_rows: Vec<&String> = lines.iter().filter(|l| l.contains("[held]")).collect();
assert_eq!(
held_rows.len(),
1,
"exactly one row must carry the [held] marker, got {held_rows:?}"
);
assert!(
held_rows[0].contains("/held"),
"the [held] marker must follow the held entry after a reorder, got {held_rows:?}"
);
}
#[test]
fn held_marker_vanishes_when_the_held_entry_is_gone() {
let mut terminal = test_terminal();
let mut app = test_app();
app.add_env_var(path_entry("/held", "gcc", "13")).unwrap();
app.add_env_var(path_entry("/a", "", "")).unwrap();
app.active_view = View::Parts;
app.parts_list_state.select(Some(0));
press(&mut terminal, &mut app, 'm');
app.replace_var_parts("PATH", vec![path_entry("/a", "", "")])
.unwrap();
draw(&mut terminal, &mut app);
let lines = buffer_lines(&terminal);
assert!(
!lines.iter().any(|l| l.contains("[held]")),
"no row may be marked held once the held entry is gone, got {lines:?}"
);
}
#[test]
fn esc_after_profile_switch_does_not_move_part_across_profiles() {
let mut terminal = test_terminal();
let mut app = test_app();
app.add_profile(EnvProfile::new("other")).unwrap();
app.add_env_var(path_entry("/opt/gcc/bin", "gcc", "13"))
.unwrap();
app.active_view = View::Parts;
app.parts_list_state.select(Some(0));
press(&mut terminal, &mut app, 'm');
app.active_profile_index = 1;
let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
handle_key_event(&mut terminal, &mut app, key).unwrap();
assert!(app.holding.is_none());
assert!(
app.profiles[1].entries.is_empty(),
"cancelling a hold must not insert the part into another profile"
);
assert_eq!(
app.profiles[0].entries.len(),
1,
"cancelling a hold must leave the part in its origin profile"
);
assert!(db::load_profile(&app.conn, "other")
.unwrap()
.entries
.is_empty());
assert_eq!(
db::load_profile(&app.conn, "default")
.unwrap()
.entries
.len(),
1
);
}
#[test]
fn duplicating_part_with_y_is_blocked_for_scalar_vars() {
let mut terminal = test_terminal();
let mut app = test_app();
app.add_env_var(Entry::CC("gcc".to_string())).unwrap();
app.active_view = View::Parts;
select_var_row(&mut app, "CC");
app.parts_list_state.select(Some(0));
press(&mut terminal, &mut app, 'y');
assert_eq!(
select::current_var_parts(&app, "CC").len(),
1,
"a scalar var must never gain a second entry via y"
);
assert!(
app.status.contains("scalar"),
"blocked duplicate must be reported on the status line, got: {}",
app.status
);
}
#[test]
fn vars_view_p_drops_held_part_into_selected_var() {
let mut terminal = test_terminal();
let mut app = test_app();
app.add_env_var(path_entry("/opt/gcc/bin", "gcc", "13"))
.unwrap();
app.active_view = View::Parts;
app.parts_list_state.select(Some(0));
press(&mut terminal, &mut app, 'm');
app.active_view = View::Vars;
let rows = select::compute_var_rows(&app);
let cpath_row = rows.iter().position(|r| r.name == "CPATH").unwrap();
app.vars_list_state.select(Some(cpath_row));
press(&mut terminal, &mut app, 'p');
assert!(app.holding.is_none(), "drop must consume the hold");
let cpath = select::current_var_parts(&app, "CPATH");
assert_eq!(
cpath.len(),
1,
"held part must be appended to the target var"
);
assert!(matches!(&cpath[0], Entry::CPath(v) if v == "/opt/gcc/bin"));
assert!(
select::current_var_parts(&app, "PATH").is_empty(),
"moving must remove the part from its origin var"
);
}
#[test]
fn live_filter_moves_parts_context_to_highlighted_var() {
let mut terminal = test_terminal();
let mut app = test_app();
app.add_env_var(path_entry("/bin", "", "")).unwrap();
app.add_env_var(Entry::CPath("/inc".to_string())).unwrap();
app.active_view = View::Vars;
press(&mut terminal, &mut app, '/');
for c in "cpath".chars() {
press(&mut terminal, &mut app, c);
}
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
handle_key_event(&mut terminal, &mut app, enter).unwrap();
app.active_view = View::Parts;
app.parts_list_state.select(Some(0));
press(&mut terminal, &mut app, 'd');
assert!(
select::current_var_parts(&app, "CPATH").is_empty(),
"d must delete a part of the var highlighted in the Vars list"
);
assert_eq!(
select::current_var_parts(&app, "PATH").len(),
1,
"the previously-selected var must not be touched"
);
}
#[test]
fn jump_to_bottom_moves_parts_context_to_highlighted_var() {
let mut terminal = test_terminal();
let mut app = test_app();
app.add_env_var(path_entry("/bin", "", "")).unwrap();
app.add_env_var(Entry::Strip("strip".to_string())).unwrap();
app.active_view = View::Vars;
press(&mut terminal, &mut app, 'g');
let rows = select::compute_var_rows(&app);
let highlighted = rows[app.vars_list_state.selected().unwrap()].name.clone();
assert_eq!(highlighted, "STRIP", "bottom row of the builtin var list");
app.active_view = View::Parts;
app.parts_list_state.select(Some(0));
press(&mut terminal, &mut app, 'd');
assert!(
select::current_var_parts(&app, "STRIP").is_empty(),
"d must delete a part of the var highlighted in the Vars list"
);
assert_eq!(
select::current_var_parts(&app, "PATH").len(),
1,
"the previously-selected var must not be touched"
);
}
fn path_order(app: &AppState) -> Vec<String> {
select::current_var_parts(app, "PATH")
.iter()
.map(|e| match e {
Entry::Path(pe) => pe.path.clone(),
_ => unreachable!(),
})
.collect()
}
#[test]
fn reorder_is_blocked_while_parts_filter_is_active() {
let mut terminal = test_terminal();
let mut app = test_app();
for p in ["/keep-one", "/hidden-a", "/keep-two", "/hidden-b"] {
app.add_env_var(path_entry(p, "", "")).unwrap();
}
app.active_view = View::Parts;
app.parts_filter = "keep".to_string();
app.parts_list_state.select(Some(1));
let original = vec!["/keep-one", "/hidden-a", "/keep-two", "/hidden-b"];
press(&mut terminal, &mut app, 'K');
assert_eq!(
path_order(&app),
original,
"K under an active filter must not swap with a hidden part"
);
assert!(
app.status.contains("clear filter"),
"blocked reorder must be reported on the status line, got: {}",
app.status
);
press(&mut terminal, &mut app, 'J');
assert_eq!(
path_order(&app),
original,
"J under an active filter must not swap with a hidden part"
);
let loaded = db::load_profile(&app.conn, "default").unwrap();
let db_order: Vec<String> = loaded
.entries
.iter()
.map(|e| match e {
Entry::Path(pe) => pe.path.clone(),
_ => unreachable!(),
})
.collect();
assert_eq!(db_order, original);
}
#[test]
fn vars_view_p_respects_scalar_guard_for_held_part() {
let mut terminal = test_terminal();
let mut app = test_app();
app.add_env_var(path_entry("/opt/gcc/bin", "gcc", "13"))
.unwrap();
app.active_view = View::Parts;
app.parts_list_state.select(Some(0));
press(&mut terminal, &mut app, 'm');
app.active_view = View::Vars;
let rows = select::compute_var_rows(&app);
let cc_row = rows.iter().position(|r| r.name == "CC").unwrap();
app.vars_list_state.select(Some(cc_row));
press(&mut terminal, &mut app, 'p');
assert!(
select::current_var_parts(&app, "CC").is_empty(),
"a held part must not be dropped into a scalar var"
);
assert!(
matches!(app.holding, Some(Holding::Part { .. })),
"a blocked drop must keep the hold"
);
assert_eq!(select::current_var_parts(&app, "PATH").len(), 1);
}
}