use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::Rect;
use crate::app::App;
use crate::presentation::app_mode::{AppMode, DiffScrollCache, HelpContext, ViewportRect};
use crate::runtime::EventResult;
use crate::ui::component::file_explorer::FileExplorer;
use crate::ui::{RenderCacheStore, page};
pub(crate) fn handle_with_cache(
app: &mut App,
render_cache_store: &RenderCacheStore,
content_area: Rect,
key: KeyEvent,
) -> EventResult {
if handle_help_key(app, key) {
return EventResult::Continue;
}
if handle_exit_key(app, key) {
return EventResult::Continue;
}
handle_navigation_key(app, render_cache_store, content_area, key);
EventResult::Continue
}
#[cfg(test)]
fn handle(app: &mut App, content_area: Rect, key: KeyEvent) -> EventResult {
handle_with_cache(app, &RenderCacheStore::default(), content_area, key)
}
fn handle_help_key(app: &mut App, key: KeyEvent) -> bool {
if key.code != KeyCode::Char('?') {
return false;
}
let mode = std::mem::replace(&mut app.mode, AppMode::List);
if let AppMode::Diff {
diff,
file_explorer_selected_index,
restore_question,
session_id,
scroll_offset,
..
} = mode
{
app.mode = AppMode::Help {
context: HelpContext::Diff {
diff,
file_explorer_selected_index,
restore_question,
session_id,
scroll_offset,
},
scroll_offset: 0,
};
} else {
app.mode = mode;
}
true
}
fn handle_exit_key(app: &mut App, key: KeyEvent) -> bool {
if !matches!(key.code, KeyCode::Char('q') | KeyCode::Esc) {
return false;
}
let mode = std::mem::replace(&mut app.mode, AppMode::List);
if let AppMode::Diff {
restore_question,
session_id,
..
} = mode
{
app.mode = if let Some(snapshot) = restore_question {
snapshot.into_question_mode()
} else {
AppMode::View {
session_id,
scroll_offset: None,
}
};
} else {
app.mode = mode;
}
true
}
fn handle_navigation_key(
app: &mut App,
render_cache_store: &RenderCacheStore,
content_area: Rect,
key: KeyEvent,
) {
let mode = std::mem::replace(&mut app.mode, AppMode::List);
let AppMode::Diff {
diff,
mut file_explorer_selected_index,
restore_question,
mut scroll_cache,
mut scroll_offset,
session_id,
} = mode
else {
app.mode = mode;
return;
};
match key.code {
KeyCode::Char('j') if is_plain_char_key(key, 'j') => {
let content = render_cache_store.diff_layout_cache().content(&diff);
let new_index = FileExplorer::next_selected_index(
file_explorer_selected_index,
content.item_count(),
);
if file_explorer_selected_index != new_index {
file_explorer_selected_index = new_index;
scroll_cache = None;
scroll_offset = 0;
}
}
KeyCode::Char('k') if is_plain_char_key(key, 'k') => {
let content = render_cache_store.diff_layout_cache().content(&diff);
let new_index = FileExplorer::previous_selected_index(
file_explorer_selected_index,
content.item_count(),
);
if file_explorer_selected_index != new_index {
file_explorer_selected_index = new_index;
scroll_cache = None;
scroll_offset = 0;
}
}
KeyCode::Down | KeyCode::Char('J' | 'j')
if key.code == KeyCode::Down || is_shift_char_key(key, 'j') =>
{
let max_scroll_offset = diff_max_scroll_offset(
&diff,
content_area,
file_explorer_selected_index,
&mut scroll_cache,
render_cache_store.diff_layout_cache(),
);
let clamped_scroll_offset = scroll_offset.min(max_scroll_offset);
scroll_offset = clamped_scroll_offset
.saturating_add(1)
.min(max_scroll_offset);
}
KeyCode::Up | KeyCode::Char('K' | 'k')
if key.code == KeyCode::Up || is_shift_char_key(key, 'k') =>
{
let max_scroll_offset = diff_max_scroll_offset(
&diff,
content_area,
file_explorer_selected_index,
&mut scroll_cache,
render_cache_store.diff_layout_cache(),
);
let clamped_scroll_offset = scroll_offset.min(max_scroll_offset);
scroll_offset = clamped_scroll_offset.saturating_sub(1);
}
_ => {}
}
app.mode = AppMode::Diff {
diff,
file_explorer_selected_index,
restore_question,
scroll_cache,
scroll_offset,
session_id,
};
}
fn is_plain_char_key(key: KeyEvent, character: char) -> bool {
key.code == KeyCode::Char(character) && key.modifiers == KeyModifiers::NONE
}
fn is_shift_char_key(key: KeyEvent, character: char) -> bool {
let lowercase_character = character.to_ascii_lowercase();
let uppercase_character = character.to_ascii_uppercase();
key.modifiers == KeyModifiers::SHIFT
&& matches!(
key.code,
KeyCode::Char(pressed)
if pressed == lowercase_character || pressed == uppercase_character
)
}
fn diff_max_scroll_offset(
diff: &str,
content_area: Rect,
selected_index: usize,
scroll_cache: &mut Option<DiffScrollCache>,
diff_layout_cache: &page::diff::DiffLayoutCache,
) -> u16 {
if let Some(cached_scroll_limit) = scroll_cache
&& cached_scroll_limit.content_area == viewport_rect(content_area)
&& cached_scroll_limit.file_explorer_selected_index == selected_index
{
return cached_scroll_limit.max_scroll_offset;
}
let max_scroll_offset = page::diff::diff_view_max_scroll_offset(
diff,
selected_index,
content_area,
diff_layout_cache,
);
*scroll_cache = Some(DiffScrollCache {
content_area: viewport_rect(content_area),
file_explorer_selected_index: selected_index,
max_scroll_offset,
});
max_scroll_offset
}
fn viewport_rect(content_area: Rect) -> ViewportRect {
ViewportRect {
height: content_area.height,
width: content_area.width,
x: content_area.x,
y: content_area.y,
}
}
#[cfg(test)]
mod tests {
use crossterm::event::KeyModifiers;
use ratatui::layout::Rect;
use super::*;
const TEST_TERMINAL_SIZE: Rect = Rect::new(0, 0, 80, 12);
fn scrollable_diff_fixture() -> String {
(0..40)
.map(|index| format!("+line {index}"))
.collect::<Vec<_>>()
.join("\n")
}
#[tokio::test]
async fn test_handle_quit_key_returns_to_view_mode() {
let (mut app, _base_dir) = crate::test_support::new_test_app().await;
app.mode = AppMode::Diff {
session_id: "session-id".into(),
diff: "diff output".to_string(),
scroll_offset: 7,
file_explorer_selected_index: 0,
restore_question: None,
scroll_cache: None,
};
let event_result = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
);
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::View {
ref session_id,
scroll_offset: None,
..
} if session_id == "session-id"
));
}
#[tokio::test]
async fn test_handle_quit_key_restores_cached_review_output() {
let (mut app, _base_dir) = crate::test_support::new_test_app().await;
app.review_cache.insert(
"session-id".into(),
crate::app::ReviewCacheEntry::Ready {
text: "Focused review".to_string(),
diff_hash: 7,
},
);
app.mode = AppMode::Diff {
session_id: "session-id".into(),
diff: "diff output".to_string(),
scroll_offset: 7,
file_explorer_selected_index: 0,
restore_question: None,
scroll_cache: None,
};
let event_result = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
);
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::View {
ref session_id,
scroll_offset: None,
..
} if session_id == "session-id"
));
}
#[tokio::test]
async fn test_handle_down_key_increments_scroll_offset() {
let (mut app, _base_dir) = crate::test_support::new_test_app().await;
app.mode = AppMode::Diff {
session_id: "session-id".into(),
diff: scrollable_diff_fixture(),
scroll_offset: 0,
file_explorer_selected_index: 0,
restore_question: None,
scroll_cache: None,
};
let event_result = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
);
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::Diff {
scroll_offset: 1,
..
}
));
}
#[tokio::test]
async fn test_handle_shift_j_increments_scroll_offset() {
let (mut app, _base_dir) = crate::test_support::new_test_app().await;
app.mode = AppMode::Diff {
session_id: "session-id".into(),
diff: scrollable_diff_fixture(),
scroll_offset: 3,
file_explorer_selected_index: 2,
restore_question: None,
scroll_cache: None,
};
let event_result = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('J'), KeyModifiers::SHIFT),
);
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::Diff {
scroll_offset: 4,
file_explorer_selected_index: 2,
..
}
));
}
#[tokio::test]
async fn test_handle_up_key_saturates_scroll_offset_at_zero() {
let (mut app, _base_dir) = crate::test_support::new_test_app().await;
app.mode = AppMode::Diff {
session_id: "session-id".into(),
diff: "diff output".to_string(),
scroll_offset: 0,
file_explorer_selected_index: 0,
restore_question: None,
scroll_cache: None,
};
let event_result = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
);
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::Diff {
scroll_offset: 0,
..
}
));
}
#[tokio::test]
async fn test_handle_shift_k_saturates_scroll_offset_at_zero() {
let (mut app, _base_dir) = crate::test_support::new_test_app().await;
app.mode = AppMode::Diff {
session_id: "session-id".into(),
diff: "diff output".to_string(),
scroll_offset: 0,
file_explorer_selected_index: 2,
restore_question: None,
scroll_cache: None,
};
let event_result = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('K'), KeyModifiers::SHIFT),
);
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::Diff {
scroll_offset: 0,
file_explorer_selected_index: 2,
..
}
));
}
#[tokio::test]
async fn test_handle_non_diff_mode_leaves_mode_unchanged() {
let (mut app, _base_dir) = crate::test_support::new_test_app().await;
app.mode = AppMode::List;
let event_result = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
);
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(app.mode, AppMode::List));
}
#[tokio::test]
async fn test_handle_j_resets_scroll_offset() {
let (mut app, _base_dir) = crate::test_support::new_test_app().await;
app.mode = AppMode::Diff {
session_id: "session-id".into(),
diff: "diff --git a/src/main.rs b/src/main.rs\n+added".to_string(),
scroll_offset: 10,
file_explorer_selected_index: 0,
restore_question: None,
scroll_cache: None,
};
handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
);
assert!(matches!(
app.mode,
AppMode::Diff {
scroll_offset: 0,
file_explorer_selected_index: 1,
..
}
));
}
#[tokio::test]
async fn test_handle_j_wraps_file_selection_from_last_to_first() {
let (mut app, _base_dir) = crate::test_support::new_test_app().await;
app.mode = AppMode::Diff {
session_id: "session-id".into(),
diff: "diff --git a/src/main.rs b/src/main.rs\n+added".to_string(),
scroll_offset: 10,
file_explorer_selected_index: 1,
restore_question: None,
scroll_cache: None,
};
handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
);
assert!(matches!(
app.mode,
AppMode::Diff {
scroll_offset: 0,
file_explorer_selected_index: 0,
..
}
));
}
#[tokio::test]
async fn test_handle_k_resets_scroll_offset() {
let (mut app, _base_dir) = crate::test_support::new_test_app().await;
app.mode = AppMode::Diff {
session_id: "session-id".into(),
diff: "diff --git a/src/main.rs b/src/main.rs\n+added".to_string(),
scroll_offset: 10,
file_explorer_selected_index: 1,
restore_question: None,
scroll_cache: None,
};
handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE),
);
assert!(matches!(
app.mode,
AppMode::Diff {
scroll_offset: 0,
file_explorer_selected_index: 0,
..
}
));
}
#[tokio::test]
async fn test_handle_k_wraps_file_selection_from_first_to_last() {
let (mut app, _base_dir) = crate::test_support::new_test_app().await;
app.mode = AppMode::Diff {
session_id: "session-id".into(),
diff: "diff --git a/src/main.rs b/src/main.rs\n+added".to_string(),
scroll_offset: 10,
file_explorer_selected_index: 0,
restore_question: None,
scroll_cache: None,
};
handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE),
);
assert!(matches!(
app.mode,
AppMode::Diff {
scroll_offset: 0,
file_explorer_selected_index: 1,
..
}
));
}
#[tokio::test]
async fn test_handle_question_mark_opens_help_overlay() {
let (mut app, _base_dir) = crate::test_support::new_test_app().await;
app.mode = AppMode::Diff {
session_id: "session-id".into(),
diff: "diff output".to_string(),
scroll_offset: 5,
file_explorer_selected_index: 3,
restore_question: None,
scroll_cache: None,
};
let event_result = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE),
);
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::Help {
context: HelpContext::Diff {
ref session_id,
ref diff,
scroll_offset: 5,
file_explorer_selected_index: 3,
..
},
scroll_offset: 0,
} if session_id == "session-id" && diff == "diff output"
));
}
#[tokio::test]
async fn test_handle_down_key_clamps_scroll_offset_at_bottom() {
let (mut app, _base_dir) = crate::test_support::new_test_app().await;
let diff = scrollable_diff_fixture();
let diff_layout_cache = page::diff::DiffLayoutCache::default();
let max_scroll_offset =
diff_max_scroll_offset(&diff, TEST_TERMINAL_SIZE, 0, &mut None, &diff_layout_cache);
app.mode = AppMode::Diff {
session_id: "session-id".into(),
diff,
scroll_offset: max_scroll_offset,
file_explorer_selected_index: 0,
restore_question: None,
scroll_cache: None,
};
let event_result = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
);
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::Diff {
scroll_offset,
..
} if scroll_offset == max_scroll_offset
));
}
#[tokio::test]
async fn test_handle_up_key_recovers_immediately_from_overscrolled_state() {
let (mut app, _base_dir) = crate::test_support::new_test_app().await;
let diff = scrollable_diff_fixture();
let diff_layout_cache = page::diff::DiffLayoutCache::default();
let max_scroll_offset =
diff_max_scroll_offset(&diff, TEST_TERMINAL_SIZE, 0, &mut None, &diff_layout_cache);
app.mode = AppMode::Diff {
session_id: "session-id".into(),
diff,
scroll_offset: u16::MAX,
file_explorer_selected_index: 0,
restore_question: None,
scroll_cache: None,
};
let event_result = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
);
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::Diff {
scroll_offset,
..
} if scroll_offset == max_scroll_offset.saturating_sub(1)
));
}
#[tokio::test]
async fn test_handle_quit_with_question_snapshot_restores_question_mode() {
use crate::domain::input::InputState;
use crate::domain::question::QuestionItem;
use crate::presentation::app_mode::QuestionModeSnapshot;
let (mut app, _base_dir) = crate::test_support::new_test_app().await;
app.mode = AppMode::Diff {
session_id: "session-q".into(),
diff: "diff output".to_string(),
scroll_offset: 0,
file_explorer_selected_index: 0,
restore_question: Some(QuestionModeSnapshot {
at_mention_state: None,
current_index: 0,
input: InputState::default(),
questions: vec![QuestionItem {
options: Vec::new(),
text: "Q?".to_string(),
}],
responses: Vec::new(),
scroll_offset: None,
selected_option_index: None,
session_id: "session-q".into(),
}),
scroll_cache: None,
};
let event_result = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
);
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::Question {
ref session_id,
focus: crate::presentation::app_mode::ChatFocus::Input,
..
} if session_id == "session-q"
));
}
#[tokio::test]
async fn test_handle_question_then_help_then_exit_preserves_restore_question() {
use crate::domain::input::InputState;
use crate::domain::question::QuestionItem;
use crate::presentation::app_mode::QuestionModeSnapshot;
let (mut app, _base_dir) = crate::test_support::new_test_app().await;
let snapshot = QuestionModeSnapshot {
at_mention_state: None,
current_index: 1,
input: InputState::default(),
questions: vec![
QuestionItem {
options: Vec::new(),
text: "Q1?".to_string(),
},
QuestionItem {
options: Vec::new(),
text: "Q2?".to_string(),
},
],
responses: vec!["answer-1".to_string()],
scroll_offset: None,
selected_option_index: None,
session_id: "session-q".into(),
};
app.mode = AppMode::Diff {
session_id: "session-q".into(),
diff: "diff output".to_string(),
scroll_offset: 3,
file_explorer_selected_index: 1,
restore_question: Some(snapshot),
scroll_cache: None,
};
handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE),
);
assert!(matches!(
app.mode,
AppMode::Help {
context: HelpContext::Diff {
restore_question: Some(_),
..
},
..
}
));
crate::runtime::mode::help::handle(
&mut app,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
);
assert!(matches!(
app.mode,
AppMode::Diff {
restore_question: Some(_),
..
}
));
handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
);
assert!(matches!(
app.mode,
AppMode::Question {
ref session_id,
current_index: 1,
focus: crate::presentation::app_mode::ChatFocus::Input,
..
} if session_id == "session-q"
));
}
}