use crate::actions::{
diff_launch_outcome, dispatch_key_outcome, editor_launch_outcome, execute_confirm_action,
execute_palette_action, kick_scan, open_repo_url,
};
use crate::app::{self, App};
use crate::event::AppEvent;
use crossterm::event::{KeyCode, KeyEvent, MouseEvent, MouseEventKind};
use ratatui::Terminal;
pub async fn handle_key<B: ratatui::backend::Backend>(
key: KeyEvent,
app: &mut App,
terminal: &mut Terminal<B>,
tx: tokio::sync::mpsc::Sender<AppEvent>,
) -> Result<bool, Box<dyn std::error::Error>>
where
B::Error: 'static,
{
if app.confirm_modal().is_some() {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
execute_confirm_action(app, tx.clone()).await?;
}
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
app.dismiss_confirm();
}
_ => {}
}
return Ok(false);
}
if app.palette_visible() {
match key.code {
KeyCode::Esc => {
app.close_palette();
}
KeyCode::Char('q') if app.palette().mode == Some(app::PaletteMode::Menu) => {
app.hide_palette();
}
KeyCode::Char('j') | KeyCode::Down => {
app.palette_select_next();
}
KeyCode::Char('k') | KeyCode::Up => {
app.palette_select_prev();
}
KeyCode::Enter => {
if app.palette().selected_idx < app.palette().items.len() {
let action = app.palette().items[app.palette().selected_idx].clone();
if action.enabled {
app.close_palette();
execute_palette_action(&action, app, terminal, tx.clone()).await?;
}
}
}
KeyCode::Backspace => {
if app.palette().mode == Some(app::PaletteMode::Command) {
app.palette_backspace();
}
}
KeyCode::Char(c) => {
if app.palette().mode == Some(app::PaletteMode::Command) {
app.palette_type_char(c);
} else if let Some(pos) = app
.palette()
.items
.iter()
.position(|a| a.key.to_lowercase() == c.to_string().to_lowercase())
{
let action = app.palette().items[pos].clone();
if action.enabled {
app.hide_palette();
execute_palette_action(&action, app, terminal, tx.clone()).await?;
}
}
}
_ => {}
}
return Ok(false);
}
if key.code == KeyCode::Char('T') && !app.filter().active() {
app.toggle_theme();
return Ok(false);
}
if key.code == KeyCode::Char(';') {
app.open_palette_menu();
return Ok(false);
}
if key.code == KeyCode::Char('p')
&& key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL)
{
app.open_palette_command();
return Ok(false);
}
match app.view_mode() {
app::ViewMode::DirectoryTree => {
if app.filter().active() {
match key.code {
KeyCode::Esc => {
app.filter_mut().cancel();
}
KeyCode::Enter => {
app.commit_filter();
}
KeyCode::Char('f') => {
app.filter_mut().toggle_diffs_only();
}
_ => {
app.filter_mut().input_mut().apply_edit(key.code);
}
}
} else {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => return Ok(true),
KeyCode::Char('j') | KeyCode::Down => app.select_next(),
KeyCode::Char('k') | KeyCode::Up => app.select_prev(),
KeyCode::Char('f')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
app.page_down();
}
KeyCode::Char('b')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
app.page_up();
}
KeyCode::Char(' ') => app.toggle_expand(),
KeyCode::Char('h') | KeyCode::Left => app.collapse_selected(),
KeyCode::Char('l') | KeyCode::Right => app.expand_selected(),
KeyCode::Tab => app.toggle_active_side(),
KeyCode::Char('1') => {
app.focus_left_pane();
}
KeyCode::Char('2') => {
app.focus_right_pane();
}
KeyCode::Char('c') => {
app.toggle_precise_mode();
kick_scan(app, tx.clone());
}
KeyCode::Char('r') => {
kick_scan(app, tx.clone());
}
KeyCode::Char('s') => {
app.swap_paths();
app.set_status("Swapped left ↔ right", false);
kick_scan(app, tx.clone());
}
KeyCode::Char('C') => {
app.open_config();
}
KeyCode::Char('/') => {
app.filter_mut().open();
}
KeyCode::Char('?') => {
app.open_help();
}
KeyCode::Backspace
if !app.filter().pattern().is_empty() || app.filter().diffs_only() =>
{
app.clear_filter();
}
KeyCode::Char('L') if app.selected_row().is_some() => {
app.request_copy(app::ConfirmAction::CopyRightToLeft);
}
KeyCode::Char('R') if app.selected_row().is_some() => {
app.request_copy(app::ConfirmAction::CopyLeftToRight);
}
KeyCode::Char('D') if app.selected_row().is_some() => {
dispatch_key_outcome(
diff_launch_outcome(app),
terminal,
app.mouse_enabled(),
)?;
}
KeyCode::Char('E') if app.selected_row().is_some() => {
dispatch_key_outcome(
editor_launch_outcome(app),
terminal,
app.mouse_enabled(),
)?;
}
KeyCode::Enter if app.selected_row().is_some() => {
let row = app.selected_row().unwrap();
if row.is_dir() {
app.toggle_expand();
} else {
app.enter_file_diff();
}
}
_ => {}
}
}
}
app::ViewMode::FileDiff => match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
app.leave_file_diff();
}
KeyCode::Down if key.modifiers.contains(crossterm::event::KeyModifiers::ALT) => {
app.jump_to_next_change();
}
KeyCode::Up if key.modifiers.contains(crossterm::event::KeyModifiers::ALT) => {
app.jump_to_prev_change();
}
KeyCode::Char('N') => {
app.jump_to_next_change();
}
KeyCode::Char('P') => {
app.jump_to_prev_change();
}
KeyCode::Char('j') | KeyCode::Down => {
app.diff_scroll_down();
}
KeyCode::Char('k') | KeyCode::Up => {
app.diff_mut().scroll_up();
}
KeyCode::Char('f')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
app.diff_page_down();
}
KeyCode::Char('b')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
app.diff_page_up();
}
KeyCode::Left => {
app.diff_mut().h_scroll_left();
}
KeyCode::Right => {
app.diff_h_scroll_right();
}
KeyCode::Char('L') | KeyCode::Char('l') if app.selected_row().is_some() => {
app.request_copy(app::ConfirmAction::CopyRightToLeft);
}
KeyCode::Char('R') | KeyCode::Char('r') if app.selected_row().is_some() => {
app.request_copy(app::ConfirmAction::CopyLeftToRight);
}
KeyCode::Char('[') => {
match app.copy_hunk_at_cursor(crate::diff_view::HunkCopyDirection::RightToLeft) {
Ok(()) => app.set_status("Copied change block to left".to_string(), false),
Err(e) => app.set_status(format!("Hunk copy failed: {}", e), true),
}
}
KeyCode::Char(']') => {
match app.copy_hunk_at_cursor(crate::diff_view::HunkCopyDirection::LeftToRight) {
Ok(()) => app.set_status("Copied change block to right".to_string(), false),
Err(e) => app.set_status(format!("Hunk copy failed: {}", e), true),
}
}
KeyCode::Char('w') => {
app.diff_mut().toggle_wrap();
}
KeyCode::Char('?') => {
app.open_help();
}
KeyCode::Char('C') => {
app.open_config();
}
KeyCode::Char('f') => {
if let Err(e) = app.toggle_diff_show_full() {
app.set_status(format!("Cannot refresh diff: {e}"), true);
}
}
_ => {}
},
app::ViewMode::ConfigMenu => match key.code {
KeyCode::Esc | KeyCode::Char('q') => app.close_config(),
KeyCode::Char('j') | KeyCode::Down => {
app.config_select_next();
}
KeyCode::Char('k') | KeyCode::Up => {
app.config_select_prev();
}
KeyCode::Char(' ') | KeyCode::Enter => {
app.apply_config_selection();
}
KeyCode::Char('h') | KeyCode::Left => {
app.adjust_config_selection(false);
}
KeyCode::Char('l') | KeyCode::Right => {
app.adjust_config_selection(true);
}
KeyCode::Char('?') => {
app.open_help();
}
_ => {}
},
app::ViewMode::Help => match key.code {
KeyCode::Char('j') | KeyCode::Down => {
app.help_mut().move_down();
}
KeyCode::Char('k') | KeyCode::Up => {
app.help_mut().move_up();
}
KeyCode::Char(c @ '1'..='6') => {
app.help_mut()
.select_topic_by_index((c as u8 - b'1') as usize);
}
KeyCode::Enter if app.help().index_open() => {
let idx = app.help().index_sel();
app.help_mut().select_topic_by_index(idx);
}
KeyCode::Tab if !app.help().index_open() => {
app.help_mut().open_index();
}
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?') => {
app.close_help();
}
KeyCode::Char('C') => {
app.open_config();
}
_ => {}
},
}
Ok(false)
}
pub async fn handle_mouse<B: ratatui::backend::Backend>(
mouse: MouseEvent,
app: &mut App,
terminal: &mut Terminal<B>,
tx: tokio::sync::mpsc::Sender<AppEvent>,
) -> Result<(), Box<dyn std::error::Error>>
where
B::Error: 'static,
{
if app.confirm_modal().is_some() {
if let MouseEventKind::Down(crossterm::event::MouseButton::Left) = mouse.kind {
if let Ok(size) = terminal.size() {
let size_rect = ratatui::prelude::Rect::new(0, 0, size.width, size.height);
let modal_area = crate::ui::centered_rect(60, 7, size_rect);
if mouse.row == modal_area.y
&& mouse.column >= modal_area.x + modal_area.width.saturating_sub(5)
&& mouse.column < modal_area.x + modal_area.width.saturating_sub(2)
{
app.dismiss_confirm();
}
}
}
return Ok(());
}
if let MouseEventKind::Down(crossterm::event::MouseButton::Left) = mouse.kind {
if mouse.row == 0 {
if let Ok(size) = terminal.size() {
let bar_area = ratatui::prelude::Rect::new(0, 0, size.width, 1);
let links = crate::ui::top_bar_links(bar_area);
if links.config.x <= mouse.column
&& mouse.column < links.config.x + links.config.width
{
app.close_palette();
app.open_config();
return Ok(());
} else if links.help.x <= mouse.column
&& mouse.column < links.help.x + links.help.width
{
app.close_palette();
app.open_help();
return Ok(());
}
}
} else if app.palette_visible() {
if let Ok(size) = terminal.size() {
let mode = app.palette().mode.unwrap_or(app::PaletteMode::Menu);
let count = app.palette().items.len();
let size_rect = ratatui::prelude::Rect::new(0, 0, size.width, size.height);
let popup = crate::ui::palette_popup_rect(mode, count, size_rect);
let menu_x = popup.x;
let menu_y = popup.y;
let pop_w = popup.width;
let pop_h = popup.height;
if mouse.column >= menu_x
&& mouse.column < menu_x + pop_w
&& mouse.row >= menu_y
&& mouse.row < menu_y + pop_h
{
if mouse.row == menu_y
&& mouse.column >= menu_x + pop_w.saturating_sub(5)
&& mouse.column < menu_x + pop_w.saturating_sub(2)
{
app.close_palette();
return Ok(());
}
let list_start_y = match mode {
app::PaletteMode::Menu => menu_y + 1,
app::PaletteMode::Command => menu_y + 3,
};
if mouse.row >= list_start_y && mouse.row < menu_y + pop_h - 1 {
let click_idx = (mouse.row - list_start_y) as usize;
if click_idx < app.palette().items.len() {
let action = app.palette().items[click_idx].clone();
if action.enabled {
app.close_palette();
execute_palette_action(&action, app, terminal, tx.clone()).await?;
}
}
}
} else {
app.close_palette();
}
}
return Ok(());
} else {
if let Ok(size) = terminal.size() {
if app.view_mode() == app::ViewMode::Help {
let body_area = ratatui::prelude::Rect::new(0, 1, size.width, 1);
if let Some(button) = crate::ui::close_button_rect(body_area) {
if mouse.row == button.y
&& mouse.column >= button.x
&& mouse.column < button.x + button.width
{
app.close_help();
return Ok(());
}
}
} else if app.view_mode() == app::ViewMode::ConfigMenu {
let body_area = ratatui::prelude::Rect::new(0, 1, size.width, 1);
if let Some(button) = crate::ui::close_button_rect(body_area) {
if mouse.row == button.y
&& mouse.column >= button.x
&& mouse.column < button.x + button.width
{
app.close_config();
return Ok(());
}
}
} else if app.view_mode() == app::ViewMode::FileDiff {
let size_rect = ratatui::prelude::Rect::new(0, 0, size.width, size.height);
let inputs = app.diff_layout_inputs();
let layout = crate::ui::diff_layout(&inputs, size_rect);
if mouse.row == layout.right.y
&& mouse.column >= size.width.saturating_sub(5)
&& mouse.column < size.width.saturating_sub(2)
{
app.leave_file_diff();
return Ok(());
}
}
}
}
}
if app.palette_visible() {
match mouse.kind {
MouseEventKind::ScrollDown => {
app.palette_select_next();
return Ok(());
}
MouseEventKind::ScrollUp => {
app.palette_select_prev();
return Ok(());
}
_ => {}
}
}
match app.view_mode() {
app::ViewMode::DirectoryTree => match mouse.kind {
MouseEventKind::ScrollDown => app.select_next(),
MouseEventKind::ScrollUp => app.select_prev(),
MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
let click_y = mouse.row as usize;
if click_y >= 2 {
let offset_y = click_y - 2;
if offset_y < app.viewport().visible_height {
let idx = app.scroll_offset() + offset_y;
if app.select_row_at(idx) && app.note_tree_click(idx) {
let row = app.selected_row().unwrap();
if row.is_dir() {
app.toggle_expand();
} else {
app.enter_file_diff();
}
}
}
}
}
MouseEventKind::Down(crossterm::event::MouseButton::Right) => {
let click_y = mouse.row as usize;
if click_y >= 2 {
let offset_y = click_y - 2;
if offset_y < app.viewport().visible_height {
let idx = app.scroll_offset() + offset_y;
if app.select_row_at(idx) {
app.open_palette_menu();
}
}
}
}
_ => {}
},
app::ViewMode::FileDiff => match mouse.kind {
MouseEventKind::ScrollDown => {
app.diff_scroll_down();
}
MouseEventKind::ScrollUp => {
app.diff_mut().scroll_up();
}
MouseEventKind::Down(crossterm::event::MouseButton::Right) => {
app.open_palette_menu();
}
_ => {}
},
app::ViewMode::ConfigMenu => match mouse.kind {
MouseEventKind::ScrollDown => {
app.config_scroll(true);
}
MouseEventKind::ScrollUp => {
app.config_scroll(false);
}
MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
let click_y = mouse.row as usize;
if click_y >= 2 {
let row_idx = click_y - 2;
if app.config_select_at(row_idx) {
app.apply_config_selection();
}
}
}
MouseEventKind::Down(crossterm::event::MouseButton::Right) => {
app.open_palette_menu();
}
_ => {}
},
app::ViewMode::Help => match mouse.kind {
MouseEventKind::ScrollDown => {
app.help_mut().move_down();
}
MouseEventKind::ScrollUp => {
app.help_mut().move_up();
}
MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
if app.help().index_open() {
let click_y = mouse.row as usize;
if click_y >= 2 {
app.help_mut().select_topic_by_index(click_y - 2);
}
} else if app.help().topic() == app::HelpTopic::About {
if let Some(visible_row) =
crate::ui::ABOUT_REPO_LINE.checked_sub(app.help().scroll())
{
if mouse.row == 2 + visible_row && mouse.column >= 3 {
open_repo_url(app);
}
}
}
}
MouseEventKind::Down(crossterm::event::MouseButton::Right) => {
app.open_palette_menu();
}
_ => {}
},
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[tokio::test]
async fn test_filter_bar_edits_cjk_text_by_char_not_byte() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
app.filter_mut().open();
let (tx, _rx) = tokio::sync::mpsc::channel(8);
for c in "你好".chars() {
handle_key(
crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(c),
crossterm::event::KeyModifiers::empty(),
),
&mut app,
&mut terminal,
tx.clone(),
)
.await
.unwrap();
}
assert_eq!(app.filter().input(), "你好");
handle_key(
crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Backspace,
crossterm::event::KeyModifiers::empty(),
),
&mut app,
&mut terminal,
tx,
)
.await
.unwrap();
assert_eq!(app.filter().input(), "你");
}
#[tokio::test]
async fn test_theme_toggle_key_from_directory_tree() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let _guard = crate::test_support::ConfigEnvGuard::new();
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
assert_eq!(app.settings().theme, crate::theme::ThemeChoice::Light);
let (tx, _rx) = tokio::sync::mpsc::channel(8);
let quit = handle_key(
crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('T'),
crossterm::event::KeyModifiers::empty(),
),
&mut app,
&mut terminal,
tx.clone(),
)
.await
.unwrap();
assert!(!quit);
assert_eq!(app.settings().theme, crate::theme::ThemeChoice::Dark);
handle_key(
crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('T'),
crossterm::event::KeyModifiers::empty(),
),
&mut app,
&mut terminal,
tx,
)
.await
.unwrap();
assert_eq!(app.settings().theme, crate::theme::ThemeChoice::Light);
}
#[tokio::test]
async fn test_theme_toggle_key_ignored_while_filtering() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
app.set_theme(crate::theme::ThemeChoice::Dark);
app.filter_mut().open();
let (tx, _rx) = tokio::sync::mpsc::channel(8);
handle_key(
crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('T'),
crossterm::event::KeyModifiers::empty(),
),
&mut app,
&mut terminal,
tx,
)
.await
.unwrap();
assert_eq!(app.settings().theme, crate::theme::ThemeChoice::Dark);
assert_eq!(app.filter().input(), "T");
}
#[tokio::test]
async fn test_config_menu_mouse_scroll_navigates_and_adjusts_diff_context() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let _guard = crate::test_support::ConfigEnvGuard::new();
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
app.set_view_mode(app::ViewMode::ConfigMenu);
let (tx, _rx) = tokio::sync::mpsc::channel(8);
let rows = app.config_rows();
let mouse_idx = rows
.iter()
.position(|r| matches!(r, app::ConfigRowKind::Mouse))
.unwrap();
let theme_idx = rows
.iter()
.position(|r| matches!(r, app::ConfigRowKind::Theme))
.unwrap();
let diff_context_idx = rows
.iter()
.position(|r| matches!(r, app::ConfigRowKind::DiffContext))
.unwrap();
app.config_mut().set_selected_idx(mouse_idx);
let scroll_down = crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollDown,
column: 0,
row: 0,
modifiers: crossterm::event::KeyModifiers::empty(),
};
let scroll_up = crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollUp,
column: 0,
row: 0,
modifiers: crossterm::event::KeyModifiers::empty(),
};
handle_mouse(scroll_down, &mut app, &mut terminal, tx.clone())
.await
.unwrap();
assert_eq!(
app.config().selected_idx(),
theme_idx,
"scroll down navigates to next selectable row"
);
handle_mouse(scroll_up, &mut app, &mut terminal, tx.clone())
.await
.unwrap();
assert_eq!(
app.config().selected_idx(),
mouse_idx,
"scroll up navigates to previous selectable row"
);
app.config_mut().set_selected_idx(diff_context_idx);
assert_eq!(app.settings().diff_context, 7);
handle_mouse(scroll_up, &mut app, &mut terminal, tx.clone())
.await
.unwrap();
assert_eq!(
app.settings().diff_context,
8,
"scroll up increases diff context"
);
assert_eq!(
app.config().selected_idx(),
diff_context_idx,
"diff context row stays selected"
);
handle_mouse(scroll_down, &mut app, &mut terminal, tx.clone())
.await
.unwrap();
handle_mouse(scroll_down, &mut app, &mut terminal, tx)
.await
.unwrap();
assert_eq!(
app.settings().diff_context,
6,
"scroll down decreases diff context"
);
}
#[tokio::test]
async fn test_help_mouse_scroll_moves_topic_body_and_index_selection() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
app.set_view_mode(app::ViewMode::Help);
let (tx, _rx) = tokio::sync::mpsc::channel(8);
let scroll_down = crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollDown,
column: 0,
row: 0,
modifiers: crossterm::event::KeyModifiers::empty(),
};
let scroll_up = crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollUp,
column: 0,
row: 0,
modifiers: crossterm::event::KeyModifiers::empty(),
};
app.help_mut().set_index_open(false);
app.help_mut().set_scroll(0);
handle_mouse(scroll_down, &mut app, &mut terminal, tx.clone())
.await
.unwrap();
assert_eq!(
app.help().scroll(),
1,
"scroll down advances the topic body"
);
handle_mouse(scroll_up, &mut app, &mut terminal, tx.clone())
.await
.unwrap();
assert_eq!(app.help().scroll(), 0, "scroll up rewinds the topic body");
handle_mouse(scroll_up, &mut app, &mut terminal, tx.clone())
.await
.unwrap();
assert_eq!(
app.help().scroll(),
0,
"scroll up saturates at 0, no underflow"
);
app.help_mut().set_index_open(true);
app.help_mut().set_index_sel(0);
handle_mouse(scroll_down, &mut app, &mut terminal, tx.clone())
.await
.unwrap();
assert_eq!(
app.help().index_sel(),
1,
"scroll down moves the index selection"
);
handle_mouse(scroll_up, &mut app, &mut terminal, tx.clone())
.await
.unwrap();
assert_eq!(
app.help().index_sel(),
0,
"scroll up moves the index selection back"
);
handle_mouse(scroll_up, &mut app, &mut terminal, tx)
.await
.unwrap();
assert_eq!(
app.help().index_sel(),
app::HelpTopic::all().len() - 1,
"scroll up wraps to the last topic"
);
}
#[tokio::test]
async fn test_palette_mouse_scroll_navigates_items_without_leaking_to_background() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
app.set_flat_rows(vec![
crate::app::FlatRow {
depth: 0,
relative_path: PathBuf::from("a.txt"),
name: "a.txt".to_string(),
state: crate::diff::DiffState::DifferentNewerLeft,
left: None,
right: None,
},
crate::app::FlatRow {
depth: 0,
relative_path: PathBuf::from("b.txt"),
name: "b.txt".to_string(),
state: crate::diff::DiffState::DifferentNewerLeft,
left: None,
right: None,
},
]);
app.apply_filter();
app.set_selected_idx(0);
app.open_palette_menu();
app.set_palette_items(vec![
crate::ui::PaletteAction {
key: "a".to_string(),
label: "Action A".to_string(),
action_id: crate::ui::PaletteActionId::Help,
enabled: true,
},
crate::ui::PaletteAction {
key: "b".to_string(),
label: "Action B".to_string(),
action_id: crate::ui::PaletteActionId::Quit,
enabled: true,
},
]);
app.set_palette_selected_idx(0);
let (tx, _rx) = tokio::sync::mpsc::channel(8);
let scroll_down = crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollDown,
column: 0,
row: 0,
modifiers: crossterm::event::KeyModifiers::empty(),
};
let scroll_up = crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollUp,
column: 0,
row: 0,
modifiers: crossterm::event::KeyModifiers::empty(),
};
handle_mouse(scroll_down, &mut app, &mut terminal, tx.clone())
.await
.unwrap();
assert_eq!(
app.palette().selected_idx,
1,
"scroll down navigates palette items"
);
assert_eq!(
app.selected_idx(),
0,
"scroll must not leak through to the background directory tree"
);
handle_mouse(scroll_up, &mut app, &mut terminal, tx)
.await
.unwrap();
assert_eq!(
app.palette().selected_idx,
0,
"scroll up navigates palette items back"
);
assert_eq!(
app.selected_idx(),
0,
"scroll must not leak through to the background directory tree"
);
}
#[tokio::test]
async fn test_diff_right_arrow_clamps_to_synced_viewport_width_not_terminal_size() {
use crate::diff_view::{DiffLine, DiffRow};
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
use ratatui::Terminal;
use similar::ChangeTag;
let backend = TestBackend::new(200, 24);
let mut terminal = Terminal::new(backend).unwrap();
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
app.set_view_mode(crate::app::ViewMode::FileDiff);
app.diff_mut().set_rows(vec![DiffRow::from((
Some(DiffLine {
tag: ChangeTag::Equal,
text: "a".repeat(100),
}),
Some(DiffLine {
tag: ChangeTag::Equal,
text: "a".repeat(100),
}),
))]);
app.sync_viewport(Rect::new(0, 0, 40, 24));
let expected_max_h_scroll = app.viewport().max_diff_h_scroll();
assert_ne!(
expected_max_h_scroll, 0,
"test setup must produce a non-trivial clamp"
);
let (tx, _rx) = tokio::sync::mpsc::channel(8);
for _ in 0..(expected_max_h_scroll + 5) {
handle_key(
crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Right,
crossterm::event::KeyModifiers::empty(),
),
&mut app,
&mut terminal,
tx.clone(),
)
.await
.unwrap();
}
assert_eq!(
app.diff().h_scroll(),
expected_max_h_scroll,
"Right-arrow must clamp to the synced viewport width, not the terminal's actual width"
);
}
#[tokio::test]
async fn test_confirm_modal_interception_identical_across_all_view_modes() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
for view_mode in [
crate::app::ViewMode::DirectoryTree,
crate::app::ViewMode::FileDiff,
crate::app::ViewMode::ConfigMenu,
crate::app::ViewMode::Help,
] {
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
app.set_view_mode(view_mode);
app.request_confirm("prompt", crate::app::ConfirmAction::CopyLeftToRight);
let (tx, _rx) = tokio::sync::mpsc::channel(8);
handle_key(
crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::empty(),
),
&mut app,
&mut terminal,
tx,
)
.await
.unwrap();
assert!(
app.confirm_modal().is_none(),
"{view_mode:?}: Esc must dismiss the confirm modal and clear the pending action"
);
assert_eq!(
app.view_mode(),
view_mode,
"{view_mode:?}: dismissing the modal must not itself change the view mode"
);
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
app.set_view_mode(view_mode);
app.request_confirm("prompt", crate::app::ConfirmAction::CopyLeftToRight);
let (tx, _rx) = tokio::sync::mpsc::channel(8);
handle_key(
crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('y'),
crossterm::event::KeyModifiers::empty(),
),
&mut app,
&mut terminal,
tx,
)
.await
.unwrap();
assert!(
app.confirm_modal().is_none(),
"{view_mode:?}: 'y' must route through execute_confirm_action"
);
}
}
#[tokio::test]
async fn test_confirm_modal_interception_identical_across_all_view_modes_for_mouse() {
use crate::diff_view::{DiffLine, DiffRow};
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
use ratatui::Terminal;
use similar::ChangeTag;
let modal_close_glyph = (66u16, 8u16);
let top_bar_help_button = (75u16, 0u16);
for view_mode in [
crate::app::ViewMode::DirectoryTree,
crate::app::ViewMode::FileDiff,
crate::app::ViewMode::ConfigMenu,
crate::app::ViewMode::Help,
] {
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
app.set_view_mode(view_mode);
match view_mode {
crate::app::ViewMode::DirectoryTree => {
app.set_flat_rows(vec![
crate::app::FlatRow {
depth: 0,
relative_path: PathBuf::from("a.txt"),
name: "a.txt".to_string(),
state: crate::diff::DiffState::DifferentNewerLeft,
left: None,
right: None,
},
crate::app::FlatRow {
depth: 0,
relative_path: PathBuf::from("b.txt"),
name: "b.txt".to_string(),
state: crate::diff::DiffState::DifferentNewerLeft,
left: None,
right: None,
},
]);
app.apply_filter();
app.set_selected_idx(0);
}
crate::app::ViewMode::FileDiff => {
app.diff_mut().set_rows(
(0..50)
.map(|i| {
DiffRow::from((
Some(DiffLine {
tag: ChangeTag::Equal,
text: format!("line {i}"),
}),
Some(DiffLine {
tag: ChangeTag::Equal,
text: format!("line {i}"),
}),
))
})
.collect(),
);
app.sync_viewport(Rect::new(0, 0, 80, 10));
assert_ne!(
app.viewport().max_diff_scroll(),
0,
"test setup must produce a non-trivial vertical clamp"
);
app.diff_mut().set_scroll(0);
}
crate::app::ViewMode::ConfigMenu => {
app.ensure_config_selection();
}
crate::app::ViewMode::Help => {
app.help_mut().set_index_open(false);
app.help_mut().set_scroll(0);
}
}
app.request_confirm("prompt", crate::app::ConfirmAction::CopyLeftToRight);
let before_selected_idx = app.selected_idx();
let before_diff_scroll = app.diff().scroll();
let before_config_selected_idx = app.config().selected_idx();
let before_help_scroll = app.help().scroll();
let (tx, _rx) = tokio::sync::mpsc::channel(8);
handle_mouse(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::ScrollDown,
column: 0,
row: 0,
modifiers: crossterm::event::KeyModifiers::empty(),
},
&mut app,
&mut terminal,
tx,
)
.await
.unwrap();
assert!(
app.confirm_modal().is_some(),
"{view_mode:?}: scroll must not dismiss the confirm modal"
);
match view_mode {
crate::app::ViewMode::DirectoryTree => assert_eq!(
app.selected_idx(), before_selected_idx,
"{view_mode:?}: scroll while the modal is open must not move the tree selection"
),
crate::app::ViewMode::FileDiff => assert_eq!(
app.diff().scroll(), before_diff_scroll,
"{view_mode:?}: scroll while the modal is open must not move the diff view"
),
crate::app::ViewMode::ConfigMenu => assert_eq!(
app.config().selected_idx(), before_config_selected_idx,
"{view_mode:?}: scroll while the modal is open must not move the config selection"
),
crate::app::ViewMode::Help => assert_eq!(
app.help().scroll(), before_help_scroll,
"{view_mode:?}: scroll while the modal is open must not move the help body"
),
}
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
app.set_view_mode(view_mode);
app.request_confirm("prompt", crate::app::ConfirmAction::CopyLeftToRight);
let (tx, _rx) = tokio::sync::mpsc::channel(8);
handle_mouse(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(
crossterm::event::MouseButton::Left,
),
column: top_bar_help_button.0,
row: top_bar_help_button.1,
modifiers: crossterm::event::KeyModifiers::empty(),
},
&mut app,
&mut terminal,
tx,
)
.await
.unwrap();
assert!(
app.confirm_modal().is_some(),
"{view_mode:?}: a top-bar button click must not dismiss the confirm modal"
);
assert_eq!(
app.view_mode(), view_mode,
"{view_mode:?}: a top-bar button click while the modal is open must not change the view mode"
);
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
app.set_view_mode(view_mode);
app.request_confirm("prompt", crate::app::ConfirmAction::CopyLeftToRight);
let (tx, _rx) = tokio::sync::mpsc::channel(8);
handle_mouse(
crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(
crossterm::event::MouseButton::Left,
),
column: modal_close_glyph.0,
row: modal_close_glyph.1,
modifiers: crossterm::event::KeyModifiers::empty(),
},
&mut app,
&mut terminal,
tx,
)
.await
.unwrap();
assert!(
app.confirm_modal().is_none(),
"{view_mode:?}: clicking the close glyph must dismiss the confirm modal and clear the pending confirm action"
);
assert_eq!(
app.view_mode(), view_mode,
"{view_mode:?}: dismissing the modal via the close glyph must not itself change the view mode"
);
}
}
#[tokio::test]
async fn test_config_close_button_mouse_click_returns_to_file_diff() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
app.set_view_mode(crate::app::ViewMode::FileDiff);
app.open_config();
assert_eq!(app.view_mode(), crate::app::ViewMode::ConfigMenu);
let (tx, _rx) = tokio::sync::mpsc::channel(8);
let click = crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: 76,
row: 1,
modifiers: crossterm::event::KeyModifiers::empty(),
};
handle_mouse(click, &mut app, &mut terminal, tx)
.await
.unwrap();
assert_eq!(app.view_mode(), crate::app::ViewMode::FileDiff);
}
#[tokio::test]
async fn test_topbar_config_link_mouse_click_opens_config() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
let (tx, _rx) = tokio::sync::mpsc::channel(8);
let links = crate::ui::top_bar_links(ratatui::prelude::Rect::new(0, 0, 80, 1));
let click = crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: links.config.x,
row: 0,
modifiers: crossterm::event::KeyModifiers::empty(),
};
handle_mouse(click, &mut app, &mut terminal, tx)
.await
.unwrap();
assert_eq!(app.view_mode(), crate::app::ViewMode::ConfigMenu);
}
#[tokio::test]
async fn test_topbar_help_link_mouse_click_opens_help() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
let (tx, _rx) = tokio::sync::mpsc::channel(8);
let links = crate::ui::top_bar_links(ratatui::prelude::Rect::new(0, 0, 80, 1));
let click = crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: links.help.x,
row: 0,
modifiers: crossterm::event::KeyModifiers::empty(),
};
handle_mouse(click, &mut app, &mut terminal, tx)
.await
.unwrap();
assert_eq!(app.view_mode(), crate::app::ViewMode::Help);
}
#[tokio::test]
async fn test_file_diff_close_button_mouse_click_returns_to_directory_tree() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
app.set_view_mode(crate::app::ViewMode::FileDiff);
let (tx, _rx) = tokio::sync::mpsc::channel(8);
let click = crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: 76,
row: 2,
modifiers: crossterm::event::KeyModifiers::empty(),
};
handle_mouse(click, &mut app, &mut terminal, tx)
.await
.unwrap();
assert_eq!(app.view_mode(), crate::app::ViewMode::DirectoryTree);
}
#[tokio::test]
async fn test_file_diff_close_button_mouse_click_accounts_for_identical_notice_row() {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
use std::time::SystemTime;
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let mut app = App::new(PathBuf::from("left"), PathBuf::from("right"));
let file_info = crate::diff::FileInfo {
is_dir: false,
size: 3,
modified: SystemTime::UNIX_EPOCH,
};
app.set_flat_rows(vec![crate::app::FlatRow {
depth: 0,
relative_path: PathBuf::from("same.txt"),
name: "same.txt".to_string(),
state: crate::diff::DiffState::Identical,
left: Some(file_info.clone()),
right: Some(file_info),
}]);
app.apply_filter();
app.set_selected_idx(0);
app.diff_mut()
.set_rows(vec![crate::diff_view::DiffRow::from((
Some(crate::diff_view::DiffLine {
tag: similar::ChangeTag::Equal,
text: "same".to_string(),
}),
Some(crate::diff_view::DiffLine {
tag: similar::ChangeTag::Equal,
text: "same".to_string(),
}),
))]);
app.set_view_mode(crate::app::ViewMode::FileDiff);
let (tx, _rx) = tokio::sync::mpsc::channel(8);
let click = crossterm::event::MouseEvent {
kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
column: 76,
row: 3,
modifiers: crossterm::event::KeyModifiers::empty(),
};
handle_mouse(click, &mut app, &mut terminal, tx)
.await
.unwrap();
assert_eq!(app.view_mode(), crate::app::ViewMode::DirectoryTree);
}
}