use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::Rect;
use crate::app::App;
use crate::presentation::app_mode::{
AppMode, DiffRestoreTarget, 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)
}
pub(crate) async fn session_diff(app: &App, session_id: &str) -> Option<String> {
let session = app
.sessions
.sessions()
.iter()
.find(|session| session.id == session_id)?;
let session_folder = session.folder.clone();
let base_branch = session.base_branch.clone();
let diff = app
.services
.git_client()
.diff(session_folder, base_branch)
.await
.unwrap_or_else(|error| format!("Failed to run git diff: {error}"));
if diff.trim().is_empty() {
return None;
}
Some(diff)
}
pub(crate) fn enter_diff_mode(
app: &mut App,
session_id: &str,
diff: String,
restore: Option<DiffRestoreTarget>,
) {
app.mode = AppMode::Diff {
diff,
file_explorer_selected_index: 0,
restore: restore.map(Box::new),
scroll_cache: None,
session_id: session_id.into(),
scroll_offset: 0,
};
}
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,
session_id,
scroll_offset,
..
} = mode
{
app.mode = AppMode::Help {
context: HelpContext::Diff {
diff,
file_explorer_selected_index,
restore,
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,
session_id,
..
} = mode
{
app.mode = if let Some(restore) = restore {
restore.into_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,
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,
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")
}
const RESTORE_DRAFT_TEXT: &str = "draft body";
fn prompt_mention_entry() -> crate::domain::file_entry::FileEntry {
crate::domain::file_entry::FileEntry {
is_dir: false,
path: "src/main.rs".to_string(),
}
}
fn non_default_prompt_snapshot() -> crate::presentation::app_mode::PromptModeSnapshot {
use crate::domain::agent::AgentKind;
use crate::domain::input::InputState;
use crate::presentation::app_mode::PromptModeSnapshot;
use crate::presentation::prompt::{
PromptAtMentionState, PromptAttachmentState, PromptHistoryState, PromptSlashStage,
PromptSlashState,
};
let mut attachment_state = PromptAttachmentState::default();
attachment_state.register_local_image(std::path::PathBuf::from("/tmp/pic.png"), 0);
let mut history_state =
PromptHistoryState::new(vec!["prev one".to_string(), "prev two".to_string()]);
history_state.draft_text = Some("saved draft".to_string());
history_state.selected_index = Some(1);
let mut slash_state = PromptSlashState::with_available_agent_kinds(vec![AgentKind::Codex]);
slash_state.stage = PromptSlashStage::Model;
slash_state.selected_index = 2;
PromptModeSnapshot {
at_mention_state: Some(PromptAtMentionState {
all_entries: vec![prompt_mention_entry()],
selected_index: 1,
}),
attachment_state,
history_state,
input: InputState::with_text(RESTORE_DRAFT_TEXT.to_string()),
scroll_offset: Some(4),
session_id: "session-p".into(),
slash_state,
}
}
fn assert_restored_prompt_composer(mode: &AppMode) {
use crate::domain::agent::AgentKind;
use crate::presentation::prompt::PromptSlashStage;
let AppMode::Prompt {
at_mention_state,
attachment_state,
focus,
history_state,
input,
scroll_offset,
slash_state,
..
} = mode
else {
unreachable!("expected AppMode::Prompt after leaving diff");
};
assert_eq!(*focus, crate::presentation::app_mode::ChatFocus::Input);
assert_eq!(input.text(), RESTORE_DRAFT_TEXT);
assert_eq!(*scroll_offset, Some(4));
assert_eq!(attachment_state.attachments.len(), 1);
assert_eq!(attachment_state.next_attachment_number, 2);
assert_eq!(
history_state.entries,
vec!["prev one".to_string(), "prev two".to_string()]
);
assert_eq!(history_state.draft_text, Some("saved draft".to_string()));
assert_eq!(history_state.selected_index, Some(1));
assert_eq!(slash_state.available_agent_kinds, vec![AgentKind::Codex]);
assert_eq!(slash_state.stage, PromptSlashStage::Model);
assert_eq!(slash_state.selected_index, 2);
let at_mention_state = at_mention_state
.as_ref()
.expect("at-mention state must survive leaving diff");
assert_eq!(at_mention_state.selected_index, 1);
assert_eq!(at_mention_state.all_entries, vec![prompt_mention_entry()]);
}
#[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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: 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: Some(Box::new(DiffRestoreTarget::Question(
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_quit_with_prompt_snapshot_restores_prompt_mode() {
use crate::domain::input::InputState;
use crate::presentation::app_mode::PromptModeSnapshot;
use crate::presentation::prompt::{
PromptAttachmentState, PromptHistoryState, PromptSlashState,
};
let (mut app, _base_dir) = crate::test_support::new_test_app().await;
app.mode = AppMode::Diff {
session_id: "session-p".into(),
diff: "diff output".to_string(),
scroll_offset: 0,
file_explorer_selected_index: 0,
restore: Some(Box::new(DiffRestoreTarget::Prompt(PromptModeSnapshot {
at_mention_state: None,
attachment_state: PromptAttachmentState::default(),
history_state: PromptHistoryState::new(Vec::new()),
input: InputState::with_text("draft text".to_string()),
scroll_offset: None,
session_id: "session-p".into(),
slash_state: PromptSlashState::default(),
}))),
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::Prompt {
focus: crate::presentation::app_mode::ChatFocus::Input,
input,
session_id,
..
} if input.text() == "draft text" && session_id == "session-p"
));
}
#[tokio::test]
async fn test_handle_quit_with_prompt_snapshot_preserves_composer_context() {
let (mut app, _base_dir) = crate::test_support::new_test_app().await;
app.mode = AppMode::Diff {
session_id: "session-p".into(),
diff: "diff output".to_string(),
scroll_offset: 0,
file_explorer_selected_index: 0,
restore: Some(Box::new(DiffRestoreTarget::Prompt(
non_default_prompt_snapshot(),
))),
scroll_cache: None,
};
handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
);
assert_restored_prompt_composer(&app.mode);
}
#[tokio::test]
async fn test_handle_prompt_then_help_then_exit_preserves_composer_context() {
let (mut app, _base_dir) = crate::test_support::new_test_app().await;
app.mode = AppMode::Diff {
session_id: "session-p".into(),
diff: "diff output".to_string(),
scroll_offset: 3,
file_explorer_selected_index: 1,
restore: Some(Box::new(DiffRestoreTarget::Prompt(
non_default_prompt_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: Some(_),
..
},
..
}
));
crate::runtime::mode::help::handle(
&mut app,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
);
assert!(matches!(
app.mode,
AppMode::Diff {
restore: Some(_),
..
}
));
handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE),
);
assert_restored_prompt_composer(&app.mode);
}
#[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: Some(Box::new(DiffRestoreTarget::Question(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: Some(_),
..
},
..
}
));
crate::runtime::mode::help::handle(
&mut app,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
);
assert!(matches!(
app.mode,
AppMode::Diff {
restore: 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"
));
}
#[tokio::test]
async fn test_handle_question_mark_in_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('?'), KeyModifiers::NONE),
);
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(app.mode, AppMode::List));
}
#[tokio::test]
async fn test_handle_navigation_key_in_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('j'), KeyModifiers::NONE),
);
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(app.mode, AppMode::List));
}
#[tokio::test]
async fn test_handle_unhandled_key_keeps_diff_mode_unchanged() {
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: 4,
file_explorer_selected_index: 2,
restore: None,
scroll_cache: None,
};
let event_result = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
);
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::Diff {
scroll_offset: 4,
file_explorer_selected_index: 2,
..
}
));
}
#[test]
fn test_diff_max_scroll_offset_returns_cached_value_on_matching_key() {
let diff = scrollable_diff_fixture();
let diff_layout_cache = page::diff::DiffLayoutCache::default();
let mut scroll_cache = Some(DiffScrollCache {
content_area: viewport_rect(TEST_TERMINAL_SIZE),
file_explorer_selected_index: 0,
max_scroll_offset: 4242,
});
let max_scroll_offset = diff_max_scroll_offset(
&diff,
TEST_TERMINAL_SIZE,
0,
&mut scroll_cache,
&diff_layout_cache,
);
assert_eq!(max_scroll_offset, 4242);
}
#[test]
#[should_panic(expected = "expected AppMode::Prompt after leaving diff")]
fn test_assert_restored_prompt_composer_rejects_non_prompt_mode() {
assert_restored_prompt_composer(&AppMode::List);
}
}