use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::Rect;
use crate::app::{App, AppEvent};
use crate::domain::input::InputState;
use crate::domain::session::SessionId;
use crate::presentation::app_mode::{
AppMode, DiffFocus, DiffLineCommentAnchor, DiffLineComments, DiffPreview,
DiffPreviewUnavailableReason, DiffRestoreTarget, DiffReviewComments, DiffScrollCache,
DiffSidebarFocus, HelpContext, PromptModeSnapshot, ViewportRect,
allows_diff_line_comment_reply,
};
use crate::presentation::prompt::{PromptAttachmentState, PromptHistoryState};
use crate::runtime::EventResult;
use crate::runtime::mode::input_key;
use crate::ui::component::file_explorer::FileExplorer;
use crate::ui::{RenderCacheStore, diff_util, page};
pub(crate) fn handle_with_cache(
app: &mut App,
render_cache_store: &RenderCacheStore,
content_area: Rect,
key: KeyEvent,
) -> EventResult {
if handle_line_comment_edit_key(app, key) {
return EventResult::Continue;
}
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) fn handle_loading(app: &mut App, key: KeyEvent) -> EventResult {
if matches!(key.code, KeyCode::Char('q') | KeyCode::Esc) {
app.cancel_diff_view_load();
}
EventResult::Continue
}
#[cfg(test)]
pub(crate) fn enter_diff_mode(
app: &mut App,
session_id: &str,
diff: String,
restore: Option<DiffRestoreTarget>,
sidebar_focus: DiffSidebarFocus,
) {
let session_id = session_id.into();
let mut review_comments = app.start_session_review_comment_load(&session_id);
if let Some(review_comments) = &mut review_comments {
review_comments.sidebar_focus = sidebar_focus;
}
app.mode = AppMode::Diff {
diff,
file_explorer_selected_index: 0,
focus: DiffFocus::Files,
line_comments: DiffLineComments::default(),
preview: DiffPreview::default(),
review_comments,
restore: restore.map(Box::new),
scroll_cache: None,
selected_diff_line_index: 0,
session_id,
scroll_offset: 0,
};
}
fn handle_help_key(app: &mut App, key: KeyEvent) -> bool {
if key.code != KeyCode::Char('?') {
return false;
}
let can_comment = can_reply_with_line_comments(app);
let mode = std::mem::replace(&mut app.mode, AppMode::List);
if let AppMode::Diff {
diff,
file_explorer_selected_index,
focus,
line_comments,
preview,
review_comments,
restore,
session_id,
selected_diff_line_index,
scroll_offset,
..
} = mode
{
app.mode = AppMode::Help {
context: HelpContext::Diff {
can_comment,
diff,
file_explorer_selected_index,
focus,
line_comments,
preview,
review_comments: review_comments.map(Box::new),
restore,
selected_diff_line_index,
session_id,
scroll_offset,
},
scroll_offset: 0,
};
} else {
app.mode = mode;
}
true
}
fn handle_exit_key(app: &mut App, key: KeyEvent) -> bool {
let should_exit = match key.code {
KeyCode::Char('q') => true,
KeyCode::Esc => !matches!(
app.mode,
AppMode::Diff {
focus: DiffFocus::Content,
..
}
),
_ => false,
};
if !should_exit {
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 can_reply_with_line_comments = can_reply_with_line_comments(app);
let mode = std::mem::replace(&mut app.mode, AppMode::List);
let AppMode::Diff {
diff,
mut file_explorer_selected_index,
mut focus,
line_comments,
mut preview,
mut review_comments,
restore,
mut scroll_cache,
mut scroll_offset,
mut selected_diff_line_index,
session_id,
} = mode
else {
app.mode = mode;
return;
};
let mut navigation = DiffKeyNavigation {
diff: &diff,
file_explorer_selected_index: &mut file_explorer_selected_index,
focus: &mut focus,
line_comments: &line_comments,
preview: &mut preview,
review_comments: &mut review_comments,
scroll_cache: &mut scroll_cache,
scroll_offset: &mut scroll_offset,
selected_diff_line_index: &mut selected_diff_line_index,
session_id: &session_id,
};
let line_comment_anchor = selected_line_comment_anchor(
render_cache_store,
key,
&navigation,
can_reply_with_line_comments,
);
let selection_changed =
apply_navigation_key(app, render_cache_store, content_area, key, &mut navigation);
if selection_changed && preview.is_enabled() {
refresh_selected_preview(
app,
render_cache_store,
&diff,
file_explorer_selected_index,
&mut preview,
&session_id,
);
}
app.mode = AppMode::Diff {
diff,
file_explorer_selected_index,
focus,
line_comments,
preview,
review_comments,
restore,
scroll_cache,
scroll_offset,
selected_diff_line_index,
session_id,
};
if let Some(anchor) = line_comment_anchor {
start_line_comment_edit(app, render_cache_store, content_area, anchor);
} else if should_submit_line_comments(app, key) {
open_line_comment_prompt(app);
}
}
pub(crate) fn should_submit_line_comments(app: &App, key: KeyEvent) -> bool {
let AppMode::Diff {
focus,
line_comments,
..
} = &app.mode
else {
return false;
};
can_reply_with_line_comments(app)
&& is_plain_char_key(key, 's')
&& *focus == DiffFocus::Content
&& !line_comments.is_editing()
&& !line_comments.comments.is_empty()
}
fn selected_line_comment_anchor(
render_cache_store: &RenderCacheStore,
key: KeyEvent,
navigation: &DiffKeyNavigation<'_>,
can_reply_with_line_comments: bool,
) -> Option<DiffLineCommentAnchor> {
let review_comments_are_focused = navigation
.review_comments
.as_ref()
.is_some_and(|review_comments| review_comments.sidebar_focus == DiffSidebarFocus::Comments);
if !can_reply_with_line_comments
|| key.code != KeyCode::Enter
|| key.modifiers != KeyModifiers::NONE
|| *navigation.focus != DiffFocus::Content
|| review_comments_are_focused
|| selected_preview_is_visible(
navigation.diff,
*navigation.file_explorer_selected_index,
render_cache_store.diff_layout_cache(),
navigation.preview,
)
{
return None;
}
render_cache_store
.diff_layout_cache()
.content(navigation.diff)
.selected_changed_line(
*navigation.file_explorer_selected_index,
*navigation.selected_diff_line_index,
)
}
fn start_line_comment_edit(
app: &mut App,
render_cache_store: &RenderCacheStore,
content_area: Rect,
anchor: DiffLineCommentAnchor,
) {
let AppMode::Diff {
diff,
file_explorer_selected_index,
line_comments,
scroll_cache,
scroll_offset,
selected_diff_line_index,
..
} = &mut app.mode
else {
return;
};
line_comments.start_editing(anchor);
*scroll_cache = None;
let layout = page::diff::diff_changed_line_layout(
diff,
line_comments,
*file_explorer_selected_index,
content_area,
render_cache_store.diff_layout_cache(),
);
let Some(selected_range) = layout.changed_line_ranges.get(*selected_diff_line_index) else {
return;
};
let viewport_height = usize::from(layout.render_layout.viewport_height);
if viewport_height == 0 {
*scroll_offset = 0;
return;
}
let comment_row = selected_range.end;
let current_start = usize::from(*scroll_offset);
let current_end = current_start.saturating_add(viewport_height);
if comment_row >= current_end {
let next_scroll_offset = comment_row
.saturating_add(1)
.saturating_sub(viewport_height);
*scroll_offset = u16::try_from(next_scroll_offset).unwrap_or(u16::MAX);
}
*scroll_offset = diff_util::clamp_diff_scroll_offset(
*scroll_offset,
layout.line_count,
layout.render_layout.viewport_height,
);
}
fn handle_line_comment_edit_key(app: &mut App, key: KeyEvent) -> bool {
let AppMode::Diff {
line_comments,
scroll_cache,
..
} = &mut app.mode
else {
return false;
};
if !line_comments.is_editing() {
return false;
}
if matches!(key.code, KeyCode::Enter | KeyCode::Esc) {
let previous_count = line_comments.comments.len();
line_comments.finish_editing();
if line_comments.comments.len() != previous_count {
*scroll_cache = None;
}
return true;
}
if let Some(command) =
input_key::command_for_key(key, input_key::InputCapabilities::SINGLE_LINE)
&& let Some(input) = line_comments.editing_input_mut()
{
input.apply(command);
}
true
}
pub(crate) fn handle_paste(app: &mut App, pasted_text: &str) {
let AppMode::Diff { line_comments, .. } = &mut app.mode else {
return;
};
let Some(input) = line_comments.editing_input_mut() else {
return;
};
input.insert_text(&input_key::normalize_single_line_pasted_text(pasted_text));
}
fn open_line_comment_prompt(app: &mut App) {
let (line_comments, restored_prompt, session_id) = match &app.mode {
AppMode::Diff {
line_comments,
restore,
session_id,
..
} => {
let restored_prompt = match restore.as_deref() {
Some(DiffRestoreTarget::Prompt(snapshot)) => Some(snapshot.clone()),
Some(DiffRestoreTarget::Question(_)) => return,
None => None,
};
(line_comments.clone(), restored_prompt, session_id.clone())
}
_ => return,
};
let Some(session) = app.sessions.session_for_id(session_id.as_str()) else {
return;
};
if !allows_diff_line_comment_reply(session, app.sessions.sessions(), None) {
return;
}
let history_entries = super::session_view::session_prompt_history_entries(session);
let slash_state = app.prompt_slash_state();
let mut snapshot = restored_prompt.unwrap_or_else(|| PromptModeSnapshot {
at_mention_state: None,
attachment_state: PromptAttachmentState::default(),
history_state: PromptHistoryState::new(history_entries),
input: InputState::default(),
scroll_offset: None,
session_id,
slash_state,
});
append_line_comments(&mut snapshot, &line_comments);
app.mode = snapshot.into_prompt_mode();
}
fn can_reply_with_line_comments(app: &App) -> bool {
let AppMode::Diff {
restore,
session_id,
..
} = &app.mode
else {
return false;
};
let Some(session) = app.sessions.session_for_id(session_id.as_str()) else {
return false;
};
allows_diff_line_comment_reply(session, app.sessions.sessions(), restore.as_deref())
}
fn append_line_comments(snapshot: &mut PromptModeSnapshot, line_comments: &DiffLineComments) {
let comment_blocks = line_comments.prompt_text();
if comment_blocks.is_empty() {
return;
}
let separator = if snapshot.input.is_empty() {
""
} else {
"\n\n"
};
let insertion = format!("{separator}{comment_blocks}");
snapshot.input.move_end();
let insertion_start = snapshot.input.cursor;
snapshot
.attachment_state
.remember_current_revision(&snapshot.input);
snapshot.input.insert_text(&insertion);
snapshot.attachment_state.sync_after_edit(
&snapshot.input,
insertion_start,
insertion_start,
snapshot.input.cursor,
);
snapshot.at_mention_state = None;
snapshot.history_state.reset_navigation();
snapshot.slash_state.reset();
}
struct DiffKeyNavigation<'a> {
diff: &'a str,
file_explorer_selected_index: &'a mut usize,
focus: &'a mut DiffFocus,
line_comments: &'a DiffLineComments,
preview: &'a mut DiffPreview,
review_comments: &'a mut Option<DiffReviewComments>,
scroll_cache: &'a mut Option<DiffScrollCache>,
scroll_offset: &'a mut u16,
selected_diff_line_index: &'a mut usize,
session_id: &'a SessionId,
}
impl DiffKeyNavigation<'_> {
fn content_navigation(&mut self) -> DiffContentNavigation<'_> {
DiffContentNavigation {
diff: self.diff,
file_explorer_selected_index: *self.file_explorer_selected_index,
focus: self.focus,
line_comments: self.line_comments,
preview: self.preview,
scroll_cache: self.scroll_cache,
scroll_offset: self.scroll_offset,
selected_diff_line_index: self.selected_diff_line_index,
}
}
}
fn apply_navigation_key(
app: &App,
render_cache_store: &RenderCacheStore,
content_area: Rect,
key: KeyEvent,
navigation: &mut DiffKeyNavigation<'_>,
) -> bool {
if apply_unfocused_scroll_key(render_cache_store, content_area, key, navigation) {
return false;
}
match key.code {
KeyCode::Char(character @ ('j' | 'k'))
if *navigation.focus == DiffFocus::Files && is_plain_char_key(key, character) =>
{
let content = render_cache_store
.diff_layout_cache()
.content(navigation.diff);
let new_index = selected_index_after_key(
character,
*navigation.file_explorer_selected_index,
content.item_count(),
);
if *navigation.file_explorer_selected_index != new_index {
*navigation.file_explorer_selected_index = new_index;
*navigation.scroll_cache = None;
*navigation.scroll_offset = 0;
*navigation.selected_diff_line_index = 0;
return true;
}
}
KeyCode::Enter if *navigation.focus == DiffFocus::Files => {
let mut content_navigation = navigation.content_navigation();
focus_selected_file_changes(&mut content_navigation, content_area, render_cache_store);
}
KeyCode::Down | KeyCode::Char('J' | 'j')
if *navigation.focus == DiffFocus::Content
&& is_content_navigation_key(key, KeyCode::Down, 'j') =>
{
let mut content_navigation = navigation.content_navigation();
move_content_selection(
&mut content_navigation,
content_area,
render_cache_store,
DiffContentDirection::Next,
);
}
KeyCode::Up | KeyCode::Char('K' | 'k')
if *navigation.focus == DiffFocus::Content
&& is_content_navigation_key(key, KeyCode::Up, 'k') =>
{
let mut content_navigation = navigation.content_navigation();
move_content_selection(
&mut content_navigation,
content_area,
render_cache_store,
DiffContentDirection::Previous,
);
}
KeyCode::Esc | KeyCode::Left if *navigation.focus == DiffFocus::Content => {
*navigation.focus = DiffFocus::Files;
}
KeyCode::Char(character @ ('f' | 'h'))
if *navigation.focus == DiffFocus::Content && is_plain_char_key(key, character) =>
{
*navigation.focus = DiffFocus::Files;
}
KeyCode::Char('p')
if *navigation.focus == DiffFocus::Files && is_plain_char_key(key, 'p') =>
{
if let Some(updated_preview) = toggle_selected_preview(
app,
render_cache_store.diff_layout_cache(),
navigation.diff,
*navigation.file_explorer_selected_index,
navigation.preview,
navigation.session_id,
) {
*navigation.preview = updated_preview;
*navigation.scroll_cache = None;
*navigation.scroll_offset = 0;
}
}
KeyCode::Char('c')
if *navigation.focus == DiffFocus::Files
&& is_plain_char_key(key, 'c')
&& navigation.review_comments.is_some() =>
{
*navigation.focus = DiffFocus::Files;
focus_review_comments(
navigation.review_comments,
navigation.scroll_cache,
navigation.scroll_offset,
);
}
_ => {}
}
false
}
fn apply_unfocused_scroll_key(
render_cache_store: &RenderCacheStore,
content_area: Rect,
key: KeyEvent,
navigation: &mut DiffKeyNavigation<'_>,
) -> bool {
if *navigation.focus != DiffFocus::Files {
return false;
}
let direction = match key.code {
KeyCode::Down | KeyCode::Char('J' | 'j')
if is_unfocused_scroll_key(key, KeyCode::Down, 'j') =>
{
DiffContentDirection::Next
}
KeyCode::Up | KeyCode::Char('K' | 'k')
if is_unfocused_scroll_key(key, KeyCode::Up, 'k') =>
{
DiffContentDirection::Previous
}
_ => return false,
};
let mut content_navigation = navigation.content_navigation();
scroll_content_by_row(
&mut content_navigation,
content_area,
render_cache_store,
direction,
);
true
}
fn is_unfocused_scroll_key(key: KeyEvent, arrow_key: KeyCode, character: char) -> bool {
key.code == arrow_key || is_shift_char_key(key, character)
}
fn is_content_navigation_key(key: KeyEvent, arrow_key: KeyCode, character: char) -> bool {
if key.code == arrow_key {
return true;
}
if is_plain_char_key(key, character) {
return true;
}
is_shift_char_key(key, character)
}
struct DiffContentNavigation<'a> {
diff: &'a str,
file_explorer_selected_index: usize,
focus: &'a mut DiffFocus,
line_comments: &'a DiffLineComments,
preview: &'a DiffPreview,
scroll_cache: &'a mut Option<DiffScrollCache>,
scroll_offset: &'a mut u16,
selected_diff_line_index: &'a mut usize,
}
#[derive(Clone, Copy)]
enum DiffContentDirection {
Next,
Previous,
}
fn focus_selected_file_changes(
navigation: &mut DiffContentNavigation<'_>,
content_area: Rect,
render_cache_store: &RenderCacheStore,
) {
let content = render_cache_store
.diff_layout_cache()
.content(navigation.diff);
let preview_is_visible = selected_preview_is_visible(
navigation.diff,
navigation.file_explorer_selected_index,
render_cache_store.diff_layout_cache(),
navigation.preview,
);
if !content.selected_item_is_file(navigation.file_explorer_selected_index) {
return;
}
if preview_is_visible {
*navigation.focus = DiffFocus::Content;
*navigation.selected_diff_line_index = 0;
return;
}
let changed_line_layout = page::diff::diff_changed_line_layout(
navigation.diff,
navigation.line_comments,
navigation.file_explorer_selected_index,
content_area,
render_cache_store.diff_layout_cache(),
);
if changed_line_layout.changed_line_count() == 0 {
return;
}
*navigation.focus = DiffFocus::Content;
*navigation.selected_diff_line_index = 0;
*navigation.scroll_offset = changed_line_layout
.changed_line_scroll_offset(
*navigation.selected_diff_line_index,
*navigation.scroll_offset,
)
.unwrap_or(*navigation.scroll_offset);
}
fn move_content_selection(
navigation: &mut DiffContentNavigation<'_>,
content_area: Rect,
render_cache_store: &RenderCacheStore,
direction: DiffContentDirection,
) {
if selected_preview_is_visible(
navigation.diff,
navigation.file_explorer_selected_index,
render_cache_store.diff_layout_cache(),
navigation.preview,
) {
scroll_content_by_row(navigation, content_area, render_cache_store, direction);
return;
}
let changed_line_layout = page::diff::diff_changed_line_layout(
navigation.diff,
navigation.line_comments,
navigation.file_explorer_selected_index,
content_area,
render_cache_store.diff_layout_cache(),
);
let line_count = changed_line_layout.changed_line_count();
*navigation.selected_diff_line_index = match direction {
DiffContentDirection::Next => (*navigation.selected_diff_line_index)
.saturating_add(1)
.min(line_count.saturating_sub(1)),
DiffContentDirection::Previous => (*navigation.selected_diff_line_index).saturating_sub(1),
};
*navigation.scroll_offset = changed_line_layout
.changed_line_scroll_offset(
*navigation.selected_diff_line_index,
*navigation.scroll_offset,
)
.unwrap_or(*navigation.scroll_offset);
}
fn scroll_content_by_row(
navigation: &mut DiffContentNavigation<'_>,
content_area: Rect,
render_cache_store: &RenderCacheStore,
direction: DiffContentDirection,
) {
let max_scroll_offset = diff_max_scroll_offset(
&DiffScrollLimitInput {
content_area,
diff: navigation.diff,
diff_layout_cache: render_cache_store.diff_layout_cache(),
line_comments: navigation.line_comments,
markdown_render_cache: render_cache_store.markdown_render_cache(),
preview: navigation.preview,
selected_index: navigation.file_explorer_selected_index,
},
navigation.scroll_cache,
);
*navigation.scroll_offset = match direction {
DiffContentDirection::Next => (*navigation.scroll_offset)
.min(max_scroll_offset)
.saturating_add(1)
.min(max_scroll_offset),
DiffContentDirection::Previous => (*navigation.scroll_offset)
.min(max_scroll_offset)
.saturating_sub(1),
};
}
fn selected_preview_is_visible(
diff: &str,
selected_file_index: usize,
diff_layout_cache: &page::diff::DiffLayoutCache,
preview: &DiffPreview,
) -> bool {
let Some(preview_path) = preview.path() else {
return false;
};
let content = diff_layout_cache.content(diff);
content.selected_markdown_path(selected_file_index) == Some(preview_path)
}
fn focus_review_comments(
review_comments: &mut Option<DiffReviewComments>,
scroll_cache: &mut Option<DiffScrollCache>,
scroll_offset: &mut u16,
) {
if let Some(review_comments) = review_comments {
review_comments.sidebar_focus = DiffSidebarFocus::Comments;
}
*scroll_cache = None;
*scroll_offset = 0;
}
fn refresh_selected_preview(
app: &mut App,
render_cache_store: &RenderCacheStore,
diff: &str,
file_explorer_selected_index: usize,
preview: &mut DiffPreview,
session_id: &SessionId,
) {
*preview = start_selected_preview_load(
app,
render_cache_store.diff_layout_cache(),
diff,
file_explorer_selected_index,
preview,
session_id,
);
}
fn selected_index_after_key(character: char, current_index: usize, item_count: usize) -> usize {
if character == 'j' {
return FileExplorer::next_selected_index(current_index, item_count);
}
FileExplorer::previous_selected_index(current_index, item_count)
}
fn toggle_selected_preview(
app: &App,
diff_layout_cache: &page::diff::DiffLayoutCache,
diff: &str,
selected_index: usize,
preview: &DiffPreview,
session_id: &str,
) -> Option<DiffPreview> {
if preview.is_enabled() {
return Some(preview.disabled());
}
selected_markdown_path(diff, selected_index, diff_layout_cache)?;
Some(start_selected_preview_load(
app,
diff_layout_cache,
diff,
selected_index,
preview,
session_id,
))
}
fn selected_markdown_path(
diff: &str,
selected_index: usize,
diff_layout_cache: &page::diff::DiffLayoutCache,
) -> Option<String> {
diff_layout_cache
.content(diff)
.selected_markdown_path(selected_index)
.map(str::to_string)
}
fn start_selected_preview_load(
app: &App,
diff_layout_cache: &page::diff::DiffLayoutCache,
diff: &str,
selected_index: usize,
preview: &DiffPreview,
session_id: &str,
) -> DiffPreview {
let request_id = preview.next_request_id();
let Some(path) = selected_markdown_path(diff, selected_index, diff_layout_cache) else {
return DiffPreview::Unsupported { request_id };
};
let Some(session_folder) = app
.sessions
.sessions()
.iter()
.find(|session| session.id == session_id)
.map(|session| session.folder.clone())
else {
return DiffPreview::Unavailable {
path,
reason: DiffPreviewUnavailableReason::LoadFailed(
"Session worktree is unavailable".to_string(),
),
request_id,
};
};
let event_sender = app.services.event_sender();
let git_client = app.services.git_client();
let loaded_path = path.clone();
let loaded_session_id = session_id.into();
tokio::spawn(async move {
let result = git_client
.read_worktree_file(session_folder, loaded_path.clone())
.await
.map_err(|error| error.to_string());
let _ = event_sender.send(AppEvent::DiffPreviewLoaded {
path: loaded_path,
request_id,
result,
session_id: loaded_session_id,
});
});
DiffPreview::Loading { path, request_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
)
}
struct DiffScrollLimitInput<'a> {
content_area: Rect,
diff: &'a str,
diff_layout_cache: &'a page::diff::DiffLayoutCache,
line_comments: &'a DiffLineComments,
markdown_render_cache: &'a crate::ui::markdown::MarkdownRenderCache,
preview: &'a DiffPreview,
selected_index: usize,
}
fn diff_max_scroll_offset(
input: &DiffScrollLimitInput<'_>,
scroll_cache: &mut Option<DiffScrollCache>,
) -> u16 {
if let Some(cached_scroll_limit) = scroll_cache
&& cached_scroll_limit.content_area == viewport_rect(input.content_area)
&& cached_scroll_limit.file_explorer_selected_index == input.selected_index
{
return cached_scroll_limit.max_scroll_offset;
}
let max_scroll_offset = page::diff::diff_view_max_scroll_offset(
input.diff,
input.line_comments,
input.selected_index,
input.content_area,
input.diff_layout_cache,
input.markdown_render_cache,
input.preview,
);
*scroll_cache = Some(DiffScrollCache {
content_area: viewport_rect(input.content_area),
file_explorer_selected_index: input.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 std::collections::HashMap;
use std::sync::Arc;
use crossterm::event::KeyModifiers;
use ratatui::layout::Rect;
use super::*;
use crate::presentation::app_mode::DiffReviewComments;
const TEST_TERMINAL_SIZE: Rect = Rect::new(0, 0, 80, 12);
async fn preview_test_app(mock_git_client: ag_git::MockGitClient) -> (App, tempfile::TempDir) {
let clients =
crate::test_support::test_app_clients().with_git_client(Arc::new(mock_git_client));
let (mut app, base_dir) = crate::test_support::new_test_app_with_clients(clients).await;
let session = crate::test_support::SessionFixtureBuilder::new()
.id("session-id")
.folder(base_dir.path().to_path_buf())
.build();
app.sessions =
crate::test_support::session_manager_with_handles(vec![session], HashMap::new()).into();
(app, base_dir)
}
async fn next_diff_preview_event(app: &mut App) -> AppEvent {
tokio::time::timeout(std::time::Duration::from_secs(1), async {
loop {
let event = app
.next_app_event()
.await
.expect("preview event channel should remain open");
if matches!(event, AppEvent::DiffPreviewLoaded { .. }) {
return event;
}
}
})
.await
.expect("diff preview event should arrive")
}
fn scrollable_diff_fixture() -> String {
format!(
"diff --git a/src/main.rs b/src/main.rs\n@@ -0,0 +1,40 @@\n{}",
(0..40)
.map(|index| format!("+line {index}"))
.collect::<Vec<_>>()
.join("\n")
)
}
fn diff_mode_fixture(
diff: &str,
file_explorer_selected_index: usize,
focus: DiffFocus,
preview: DiffPreview,
) -> AppMode {
AppMode::Diff {
diff: diff.to_string(),
file_explorer_selected_index,
focus,
line_comments: DiffLineComments::default(),
preview,
review_comments: None,
restore: None,
scroll_cache: None,
scroll_offset: 0,
selected_diff_line_index: 0,
session_id: "session-id".into(),
}
}
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,
focus: DiffFocus::Files,
line_comments: DiffLineComments::default(),
selected_diff_line_index: 0,
preview: DiffPreview::default(),
review_comments: None,
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,
focus: DiffFocus::Files,
line_comments: DiffLineComments::default(),
selected_diff_line_index: 0,
preview: DiffPreview::default(),
review_comments: None,
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_selects_next_changed_line() {
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,
focus: DiffFocus::Content,
line_comments: DiffLineComments::default(),
selected_diff_line_index: 0,
preview: DiffPreview::default(),
review_comments: None,
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: 0,
selected_diff_line_index: 1,
..
}
));
}
#[tokio::test]
async fn test_handle_plain_j_k_and_f_navigate_changed_lines_and_focus_files() {
let (mut app, _base_dir) = crate::test_support::new_test_app().await;
app.mode = diff_mode_fixture(
&scrollable_diff_fixture(),
0,
DiffFocus::Content,
DiffPreview::default(),
);
for key_code in [KeyCode::Char('j'), KeyCode::Char('k'), KeyCode::Char('f')] {
handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(key_code, KeyModifiers::NONE),
);
}
assert!(matches!(
app.mode,
AppMode::Diff {
focus: DiffFocus::Files,
selected_diff_line_index: 0,
..
}
));
}
#[tokio::test]
async fn test_handle_enter_from_files_focuses_first_changed_line() {
let (mut app, _base_dir) = crate::test_support::new_test_app().await;
app.mode = AppMode::Diff {
diff: scrollable_diff_fixture(),
file_explorer_selected_index: 1,
focus: DiffFocus::Files,
line_comments: DiffLineComments::default(),
preview: DiffPreview::default(),
review_comments: None,
restore: None,
scroll_cache: None,
scroll_offset: 0,
selected_diff_line_index: 7,
session_id: "session-id".into(),
};
let event_result = handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(
app.mode,
AppMode::Diff {
focus: DiffFocus::Content,
line_comments: DiffLineComments {
editing_index: None,
ref comments,
},
selected_diff_line_index: 0,
..
} if comments.is_empty()
));
}
#[tokio::test]
async fn test_handle_enter_ignores_folders_and_files_without_changed_lines() {
let (mut folder_app, _folder_base_dir) = crate::test_support::new_test_app().await;
let (mut unchanged_app, _unchanged_base_dir) = crate::test_support::new_test_app().await;
folder_app.mode = diff_mode_fixture(
"diff --git a/src/main.rs b/src/main.rs\n+added",
0,
DiffFocus::Files,
DiffPreview::default(),
);
unchanged_app.mode = diff_mode_fixture(
"diff --git a/README.md b/README.md\n@@ -1 +1 @@\n unchanged",
0,
DiffFocus::Files,
DiffPreview::default(),
);
for app in [&mut folder_app, &mut unchanged_app] {
handle(
app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
}
assert!(matches!(
folder_app.mode,
AppMode::Diff {
focus: DiffFocus::Files,
..
}
));
assert!(matches!(
unchanged_app.mode,
AppMode::Diff {
focus: DiffFocus::Files,
..
}
));
}
#[tokio::test]
async fn test_handle_enter_focuses_visible_markdown_preview() {
let (mut app, _base_dir) = crate::test_support::new_test_app().await;
app.mode = diff_mode_fixture(
"diff --git a/README.md b/README.md\n+changed",
0,
DiffFocus::Files,
DiffPreview::Ready {
content: "# Preview".to_string(),
path: "README.md".to_string(),
request_id: 1,
},
);
handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(matches!(
app.mode,
AppMode::Diff {
focus: DiffFocus::Content,
selected_diff_line_index: 0,
..
}
));
}
#[tokio::test]
async fn test_handle_enter_does_not_edit_line_when_review_comments_are_focused() {
let (mut app, _base_dir) = crate::test_support::new_test_app().await;
let mut review_comments = DiffReviewComments::loading(1);
review_comments.sidebar_focus = DiffSidebarFocus::Comments;
app.mode = AppMode::Diff {
diff: "diff --git a/src/main.rs b/src/main.rs\n+review();\n".to_string(),
file_explorer_selected_index: 1,
focus: DiffFocus::Files,
line_comments: DiffLineComments::default(),
selected_diff_line_index: 0,
preview: DiffPreview::default(),
review_comments: Some(review_comments),
restore: None,
scroll_cache: None,
scroll_offset: 0,
session_id: "session-id".into(),
};
handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(matches!(
app.mode,
AppMode::Diff {
line_comments: DiffLineComments { ref comments, .. },
review_comments: Some(DiffReviewComments {
sidebar_focus: DiffSidebarFocus::Comments,
..
}),
..
} if comments.is_empty()
));
}
#[tokio::test]
async fn test_handle_preview_arrow_keys_scroll_both_directions() {
let (mut app, _base_dir) = crate::test_support::new_test_app().await;
let preview_content = (0..40)
.map(|index| format!("preview line {index}"))
.collect::<Vec<_>>()
.join("\n");
app.mode = diff_mode_fixture(
"diff --git a/README.md b/README.md\n+changed",
0,
DiffFocus::Content,
DiffPreview::Ready {
content: preview_content,
path: "README.md".to_string(),
request_id: 1,
},
);
handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
);
handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
);
assert!(matches!(
app.mode,
AppMode::Diff {
focus: DiffFocus::Content,
scroll_offset: 0,
scroll_cache: Some(_),
..
}
));
}
#[tokio::test]
async fn test_handle_escape_returns_changed_line_focus_to_files() {
let (mut app, _base_dir) = crate::test_support::new_test_app().await;
app.mode = AppMode::Diff {
diff: scrollable_diff_fixture(),
file_explorer_selected_index: 0,
focus: DiffFocus::Content,
line_comments: DiffLineComments::default(),
preview: DiffPreview::default(),
review_comments: None,
restore: None,
scroll_cache: None,
scroll_offset: 4,
selected_diff_line_index: 8,
session_id: "session-id".into(),
};
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::Diff {
focus: DiffFocus::Files,
selected_diff_line_index: 8,
..
}
));
}
#[test]
fn test_selected_preview_is_visible_only_for_matching_markdown_selection() {
let diff = "diff --git a/README.md b/README.md\n+changed";
let cache = page::diff::DiffLayoutCache::default();
let matching_preview = DiffPreview::Ready {
content: "# Changed".to_string(),
path: "README.md".to_string(),
request_id: 1,
};
let stale_preview = DiffPreview::Ready {
content: "# Stale".to_string(),
path: "OTHER.md".to_string(),
request_id: 2,
};
let matching_is_visible = selected_preview_is_visible(diff, 0, &cache, &matching_preview);
let stale_is_visible = selected_preview_is_visible(diff, 0, &cache, &stale_preview);
let unsupported_is_visible = selected_preview_is_visible(
diff,
0,
&cache,
&DiffPreview::Unsupported { request_id: 3 },
);
assert!(matching_is_visible);
assert!(!stale_is_visible);
assert!(!unsupported_is_visible);
}
#[tokio::test]
async fn test_handle_shift_j_selects_next_changed_line() {
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: 0,
focus: DiffFocus::Content,
line_comments: DiffLineComments::default(),
selected_diff_line_index: 2,
preview: DiffPreview::default(),
review_comments: None,
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 {
file_explorer_selected_index: 0,
selected_diff_line_index: 3,
..
}
));
}
#[tokio::test]
async fn test_handle_file_focus_scrolls_with_arrows_and_shift_j_k() {
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,
focus: DiffFocus::Files,
line_comments: DiffLineComments::default(),
selected_diff_line_index: 7,
preview: DiffPreview::default(),
review_comments: None,
restore: None,
scroll_cache: None,
};
let navigation = [
(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), 1),
(KeyEvent::new(KeyCode::Char('J'), KeyModifiers::SHIFT), 2),
(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), 1),
(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::SHIFT), 0),
];
for (key, expected_scroll_offset) in navigation {
let event_result = handle(&mut app, TEST_TERMINAL_SIZE, key);
assert!(matches!(event_result, EventResult::Continue));
assert!(matches!(
&app.mode,
AppMode::Diff {
focus: DiffFocus::Files,
scroll_offset,
selected_diff_line_index: 7,
..
} if *scroll_offset == expected_scroll_offset
));
}
}
#[tokio::test]
async fn test_handle_up_key_saturates_changed_line_at_zero() {
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,
focus: DiffFocus::Content,
line_comments: DiffLineComments::default(),
selected_diff_line_index: 0,
preview: DiffPreview::default(),
review_comments: None,
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,
selected_diff_line_index: 0,
..
}
));
}
#[tokio::test]
async fn test_handle_shift_k_saturates_changed_line_at_zero() {
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,
focus: DiffFocus::Content,
line_comments: DiffLineComments::default(),
selected_diff_line_index: 0,
preview: DiffPreview::default(),
review_comments: None,
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: 0,
selected_diff_line_index: 0,
..
}
));
}
#[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,
focus: DiffFocus::Files,
line_comments: DiffLineComments::default(),
selected_diff_line_index: 0,
preview: DiffPreview::default(),
review_comments: None,
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,
focus: DiffFocus::Files,
line_comments: DiffLineComments::default(),
selected_diff_line_index: 0,
preview: DiffPreview::default(),
review_comments: None,
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,
focus: DiffFocus::Files,
line_comments: DiffLineComments::default(),
selected_diff_line_index: 0,
preview: DiffPreview::default(),
review_comments: None,
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,
focus: DiffFocus::Files,
line_comments: DiffLineComments::default(),
selected_diff_line_index: 0,
preview: DiffPreview::default(),
review_comments: None,
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,
focus: DiffFocus::Files,
line_comments: DiffLineComments::default(),
selected_diff_line_index: 0,
preview: DiffPreview::Ready {
content: "# Preview".to_string(),
path: "README.md".to_string(),
request_id: 6,
},
review_comments: None,
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,
focus: DiffFocus::Files,
selected_diff_line_index: 0,
preview: DiffPreview::Ready { request_id: 6, .. },
..
},
scroll_offset: 0,
} if session_id == "session-id" && diff == "diff output"
));
}
#[tokio::test]
async fn test_handle_down_key_clamps_changed_line_at_bottom() {
let (mut app, _base_dir) = crate::test_support::new_test_app().await;
let diff = scrollable_diff_fixture();
app.mode = AppMode::Diff {
session_id: "session-id".into(),
diff,
scroll_offset: u16::MAX,
file_explorer_selected_index: 0,
focus: DiffFocus::Content,
line_comments: DiffLineComments::default(),
selected_diff_line_index: 39,
preview: DiffPreview::default(),
review_comments: None,
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,
selected_diff_line_index: 39,
..
} if scroll_offset < u16::MAX
));
}
#[tokio::test]
async fn test_handle_up_key_recovers_overscrolled_changed_line() {
let (mut app, _base_dir) = crate::test_support::new_test_app().await;
let diff = scrollable_diff_fixture();
app.mode = AppMode::Diff {
session_id: "session-id".into(),
diff,
scroll_offset: u16::MAX,
file_explorer_selected_index: 0,
focus: DiffFocus::Content,
line_comments: DiffLineComments::default(),
selected_diff_line_index: 39,
preview: DiffPreview::default(),
review_comments: None,
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,
selected_diff_line_index: 38,
..
} if scroll_offset < u16::MAX
));
}
#[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,
focus: DiffFocus::Files,
line_comments: DiffLineComments::default(),
selected_diff_line_index: 0,
preview: DiffPreview::default(),
review_comments: None,
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,
};
open_line_comment_prompt(&mut app);
assert!(matches!(
&app.mode,
AppMode::Diff {
restore: Some(restore),
..
} if matches!(restore.as_ref(), DiffRestoreTarget::Question(_))
));
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,
focus: DiffFocus::Files,
line_comments: DiffLineComments::default(),
selected_diff_line_index: 0,
preview: DiffPreview::default(),
review_comments: None,
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,
focus: DiffFocus::Files,
line_comments: DiffLineComments::default(),
selected_diff_line_index: 0,
preview: DiffPreview::default(),
review_comments: None,
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,
focus: DiffFocus::Files,
line_comments: DiffLineComments::default(),
selected_diff_line_index: 0,
preview: DiffPreview::default(),
review_comments: None,
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,
focus: DiffFocus::Files,
line_comments: DiffLineComments::default(),
selected_diff_line_index: 0,
preview: DiffPreview::default(),
review_comments: None,
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,
focus: DiffFocus::Files,
line_comments: DiffLineComments::default(),
selected_diff_line_index: 0,
preview: DiffPreview::default(),
review_comments: None,
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,
..
}
));
}
#[tokio::test]
async fn test_handle_c_focuses_linked_review_comments() {
let (mut app, _base_dir) = crate::test_support::new_test_app().await;
app.mode = AppMode::Diff {
diff: "diff output".to_string(),
file_explorer_selected_index: 0,
focus: DiffFocus::Files,
line_comments: DiffLineComments::default(),
selected_diff_line_index: 0,
preview: DiffPreview::default(),
review_comments: Some(DiffReviewComments::loading(1)),
restore: None,
scroll_cache: Some(DiffScrollCache {
content_area: viewport_rect(TEST_TERMINAL_SIZE),
file_explorer_selected_index: 0,
max_scroll_offset: 3,
}),
scroll_offset: 2,
session_id: "session-id".into(),
};
handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE),
);
assert!(matches!(
app.mode,
AppMode::Diff {
review_comments: Some(DiffReviewComments {
sidebar_focus: DiffSidebarFocus::Comments,
..
}),
scroll_cache: None,
scroll_offset: 0,
..
}
));
}
#[tokio::test]
async fn test_handle_collects_inline_comments_before_building_next_turn() {
let (mut app, _base_dir) = preview_test_app(ag_git::MockGitClient::new()).await;
let diff = concat!(
"diff --git a/src/main.rs b/src/main.rs\n",
"@@ -1 +1,2 @@\n",
" fn main() {}\n",
"+println!(\"review\");\n",
"+review();\n",
);
app.mode = diff_mode_fixture(diff, 1, DiffFocus::Content, DiffPreview::default());
handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT),
);
handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
);
handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('B'), KeyModifiers::SHIFT),
);
handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(matches!(
&app.mode,
AppMode::Diff { line_comments, .. }
if line_comments.comments.len() == 2
&& line_comments.comments[0].input.text() == "A"
&& line_comments.comments[1].input.text() == "B"
));
handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE),
);
assert!(matches!(
&app.mode,
AppMode::Prompt {
focus: crate::presentation::app_mode::ChatFocus::Input,
input,
session_id,
..
} if session_id == "session-id"
&& input.text() == concat!(
"Line comments:\n",
"- src/main.rs:2 [new]: A\n",
"- src/main.rs:3 [new]: B",
)
));
}
#[tokio::test]
async fn test_merged_diff_rejects_inline_comment_creation_and_submission() {
let (mut app, _base_dir) = preview_test_app(ag_git::MockGitClient::new()).await;
app.sessions.sessions_mut()[0].status = crate::domain::session::Status::Merged;
let diff = "diff --git a/src/main.rs b/src/main.rs\n+review();\n";
app.mode = diff_mode_fixture(diff, 1, DiffFocus::Content, DiffPreview::default());
handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
assert!(matches!(
&app.mode,
AppMode::Diff { line_comments, .. } if line_comments.comments.is_empty()
));
if let AppMode::Diff { line_comments, .. } = &mut app.mode {
line_comments.start_editing(DiffLineCommentAnchor {
content: "review();".to_string(),
line: 1,
path: "src/main.rs".to_string(),
side: crate::presentation::app_mode::DiffLineSide::New,
});
line_comments
.editing_input_mut()
.expect("seeded inline comment should be editable")
.insert_text("read-only comment");
line_comments.finish_editing();
}
handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE),
);
open_line_comment_prompt(&mut app);
handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('?'), KeyModifiers::NONE),
);
assert!(matches!(
&app.mode,
AppMode::Help {
context: HelpContext::Diff {
can_comment: false,
..
},
..
}
));
}
#[tokio::test]
async fn test_start_line_comment_edit_handles_layout_edges() {
let (mut app, _base_dir) = preview_test_app(ag_git::MockGitClient::new()).await;
let diff = scrollable_diff_fixture();
let anchor = DiffLineCommentAnchor {
content: "line 39".to_string(),
line: 40,
path: "src/main.rs".to_string(),
side: crate::presentation::app_mode::DiffLineSide::New,
};
start_line_comment_edit(
&mut app,
&RenderCacheStore::default(),
TEST_TERMINAL_SIZE,
anchor.clone(),
);
assert!(matches!(app.mode, AppMode::List));
app.mode = diff_mode_fixture(&diff, 1, DiffFocus::Content, DiffPreview::default());
if let AppMode::Diff {
selected_diff_line_index,
..
} = &mut app.mode
{
*selected_diff_line_index = usize::MAX;
}
start_line_comment_edit(
&mut app,
&RenderCacheStore::default(),
TEST_TERMINAL_SIZE,
anchor.clone(),
);
assert!(matches!(
&app.mode,
AppMode::Diff {
line_comments,
scroll_offset: 0,
..
} if line_comments.is_editing()
));
app.mode = diff_mode_fixture(&diff, 1, DiffFocus::Content, DiffPreview::default());
start_line_comment_edit(
&mut app,
&RenderCacheStore::default(),
Rect::new(0, 0, 80, 0),
anchor.clone(),
);
assert!(matches!(
app.mode,
AppMode::Diff {
scroll_offset: 0,
..
}
));
app.mode = diff_mode_fixture(&diff, 1, DiffFocus::Content, DiffPreview::default());
if let AppMode::Diff {
selected_diff_line_index,
..
} = &mut app.mode
{
*selected_diff_line_index = 39;
}
start_line_comment_edit(
&mut app,
&RenderCacheStore::default(),
TEST_TERMINAL_SIZE,
anchor,
);
assert!(matches!(
app.mode,
AppMode::Diff { scroll_offset, .. } if scroll_offset > 0
));
}
#[tokio::test]
async fn test_blank_line_comment_clears_cached_layout() {
let (mut app, _base_dir) = preview_test_app(ag_git::MockGitClient::new()).await;
let diff = "diff --git a/src/main.rs b/src/main.rs\n+review();\n";
app.mode = diff_mode_fixture(diff, 1, DiffFocus::Content, DiffPreview::default());
handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
if let AppMode::Diff { scroll_cache, .. } = &mut app.mode {
*scroll_cache = Some(DiffScrollCache {
content_area: viewport_rect(TEST_TERMINAL_SIZE),
file_explorer_selected_index: 1,
max_scroll_offset: 0,
});
}
handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
);
assert!(matches!(
&app.mode,
AppMode::Diff {
line_comments,
scroll_cache: None,
..
} if line_comments.comments.is_empty()
));
}
#[tokio::test]
async fn test_line_comment_paste_ignores_non_editing_modes() {
let mut app = crate::test_support::new_test_app_without_retained_base_dir().await;
handle_paste(&mut app, "ignored");
let should_submit = should_submit_line_comments(
&app,
KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE),
);
assert!(matches!(app.mode, AppMode::List));
assert!(!should_submit);
app.mode = diff_mode_fixture(
"diff --git a/src/main.rs b/src/main.rs\n+review();\n",
1,
DiffFocus::Content,
DiffPreview::default(),
);
handle_paste(&mut app, "ignored");
assert!(matches!(
&app.mode,
AppMode::Diff { line_comments, .. } if line_comments.comments.is_empty()
));
}
#[tokio::test]
async fn test_open_line_comment_prompt_handles_mode_and_prompt_restore() {
let (mut app, _base_dir) = preview_test_app(ag_git::MockGitClient::new()).await;
app.mode = AppMode::List;
open_line_comment_prompt(&mut app);
assert!(matches!(app.mode, AppMode::List));
app.mode = diff_mode_fixture(
"diff --git a/src/main.rs b/src/main.rs\n+review();\n",
1,
DiffFocus::Content,
DiffPreview::default(),
);
if let AppMode::Diff {
line_comments,
restore,
..
} = &mut app.mode
{
line_comments.start_editing(DiffLineCommentAnchor {
content: "review();".to_string(),
line: 1,
path: "src/main.rs".to_string(),
side: crate::presentation::app_mode::DiffLineSide::New,
});
line_comments
.editing_input_mut()
.expect("comment should be editable")
.insert_text("Explain this call");
line_comments.finish_editing();
*restore = Some(Box::new(DiffRestoreTarget::Prompt(
non_default_prompt_snapshot(),
)));
}
open_line_comment_prompt(&mut app);
assert!(matches!(
&app.mode,
AppMode::Prompt { input, .. }
if input.text().starts_with(RESTORE_DRAFT_TEXT)
&& input.text().ends_with("Explain this call")
));
app.mode = diff_mode_fixture(
"diff --git a/src/main.rs b/src/main.rs\n+review();\n",
1,
DiffFocus::Content,
DiffPreview::default(),
);
if let AppMode::Diff { session_id, .. } = &mut app.mode {
*session_id = "missing-session".into();
}
open_line_comment_prompt(&mut app);
assert!(matches!(app.mode, AppMode::Diff { .. }));
}
#[tokio::test]
async fn test_handle_paste_inserts_first_line_into_active_diff_comment() {
let (mut app, _base_dir) = preview_test_app(ag_git::MockGitClient::new()).await;
let diff = "diff --git a/src/main.rs b/src/main.rs\n+review();\n";
app.mode = diff_mode_fixture(diff, 1, DiffFocus::Content, DiffPreview::default());
handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
);
handle_paste(&mut app, "first line\r\nsecond line");
assert!(matches!(
&app.mode,
AppMode::Diff { line_comments, .. }
if line_comments.comments[0].input.text() == "first line"
));
}
#[test]
fn test_append_line_comment_preserves_draft_and_attachment() {
let mut snapshot = non_default_prompt_snapshot();
let mut line_comments = DiffLineComments::default();
let anchor = DiffLineCommentAnchor {
content: "updated()".to_string(),
line: 9,
path: "src/lib.rs".to_string(),
side: crate::presentation::app_mode::DiffLineSide::Old,
};
line_comments.start_editing(anchor);
line_comments
.editing_input_mut()
.expect("comment should be editable")
.insert_text("Update this call");
line_comments.finish_editing();
append_line_comments(&mut snapshot, &line_comments);
assert!(snapshot.input.text().starts_with(RESTORE_DRAFT_TEXT));
assert!(snapshot.input.text().ends_with(
"Line comments:\n- src/lib.rs:9 [old, source=\"updated()\"]: Update this call"
));
assert_eq!(snapshot.attachment_state.attachments.len(), 1);
assert_eq!(snapshot.history_state.selected_index, None);
assert!(snapshot.at_mention_state.is_none());
let empty_comments = DiffLineComments::default();
let unchanged_text = snapshot.input.text().to_string();
append_line_comments(&mut snapshot, &empty_comments);
assert_eq!(snapshot.input.text(), unchanged_text);
}
#[tokio::test]
async fn test_handle_preview_key_loads_renders_event_and_toggles_off() {
let mut mock_git_client = ag_git::MockGitClient::new();
mock_git_client
.expect_read_worktree_file()
.withf(|_, path| path == "README.md")
.times(1)
.returning(|_, _| {
Box::pin(async {
Ok(ag_git::WorktreeFileContent::Text(
"# Rendered preview".to_string(),
))
})
});
let (mut app, _base_dir) = preview_test_app(mock_git_client).await;
app.mode = AppMode::Diff {
diff: "diff --git a/README.md b/README.md\n+preview".to_string(),
file_explorer_selected_index: 0,
focus: DiffFocus::Files,
line_comments: DiffLineComments::default(),
selected_diff_line_index: 0,
preview: DiffPreview::default(),
review_comments: None,
restore: None,
scroll_cache: None,
scroll_offset: 7,
session_id: "session-id".into(),
};
handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE),
);
let event = next_diff_preview_event(&mut app).await;
app.apply_app_events(event).await;
let ready = matches!(
app.mode,
AppMode::Diff {
preview: DiffPreview::Ready {
ref content,
request_id: 1,
..
},
scroll_offset: 0,
..
} if content == "# Rendered preview"
);
handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE),
);
assert!(ready);
assert!(matches!(
app.mode,
AppMode::Diff {
preview: DiffPreview::Off { request_id: 2 },
scroll_offset: 0,
..
}
));
}
#[tokio::test]
async fn test_handle_preview_key_ignores_non_markdown_selection() {
let (mut app, _base_dir) = crate::test_support::new_test_app().await;
app.mode = AppMode::Diff {
diff: "diff --git a/docs/README.md b/docs/README.md\n+preview".to_string(),
file_explorer_selected_index: 0,
focus: DiffFocus::Files,
line_comments: DiffLineComments::default(),
selected_diff_line_index: 0,
preview: DiffPreview::default(),
review_comments: None,
restore: None,
scroll_cache: None,
scroll_offset: 3,
session_id: "session-id".into(),
};
handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE),
);
assert!(matches!(
app.mode,
AppMode::Diff {
preview: DiffPreview::Off { request_id: 0 },
scroll_offset: 3,
..
}
));
}
#[tokio::test]
async fn test_handle_selection_change_keeps_preview_sticky_for_unsupported_row() {
let (mut app, _base_dir) = crate::test_support::new_test_app().await;
app.mode = AppMode::Diff {
diff: "diff --git a/docs/README.md b/docs/README.md\n+preview".to_string(),
file_explorer_selected_index: 1,
focus: DiffFocus::Files,
line_comments: DiffLineComments::default(),
selected_diff_line_index: 0,
preview: DiffPreview::Ready {
content: "# Preview".to_string(),
path: "docs/README.md".to_string(),
request_id: 4,
},
review_comments: None,
restore: None,
scroll_cache: None,
scroll_offset: 6,
session_id: "session-id".into(),
};
handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE),
);
assert!(matches!(
app.mode,
AppMode::Diff {
file_explorer_selected_index: 0,
focus: DiffFocus::Files,
selected_diff_line_index: 0,
preview: DiffPreview::Unsupported { request_id: 5 },
scroll_offset: 0,
..
}
));
}
#[tokio::test]
async fn test_handle_selection_change_reloads_next_markdown_file() {
let mut mock_git_client = ag_git::MockGitClient::new();
mock_git_client
.expect_read_worktree_file()
.withf(|_, path| path == "SECOND.MD")
.times(1)
.returning(|_, _| {
Box::pin(async { Ok(ag_git::WorktreeFileContent::Text("# Second".to_string())) })
});
let (mut app, _base_dir) = preview_test_app(mock_git_client).await;
app.mode = AppMode::Diff {
diff: concat!(
"diff --git a/README.md b/README.md\n+first\n",
"diff --git a/SECOND.MD b/SECOND.MD\n+second\n",
)
.to_string(),
file_explorer_selected_index: 0,
focus: DiffFocus::Files,
line_comments: DiffLineComments::default(),
selected_diff_line_index: 0,
preview: DiffPreview::Ready {
content: "# First".to_string(),
path: "README.md".to_string(),
request_id: 2,
},
review_comments: None,
restore: None,
scroll_cache: None,
scroll_offset: 4,
session_id: "session-id".into(),
};
handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
);
let preview_event = next_diff_preview_event(&mut app).await;
assert!(matches!(
app.mode,
AppMode::Diff {
file_explorer_selected_index: 1,
focus: DiffFocus::Files,
selected_diff_line_index: 0,
preview: DiffPreview::Loading {
ref path,
request_id: 3,
},
scroll_offset: 0,
..
} if path == "SECOND.MD"
));
assert!(matches!(
preview_event,
AppEvent::DiffPreviewLoaded { ref path, .. } if path == "SECOND.MD"
));
}
#[tokio::test]
async fn test_handle_preview_key_reports_missing_session_worktree() {
let (mut app, _base_dir) = crate::test_support::new_test_app().await;
app.mode = AppMode::Diff {
diff: "diff --git a/README.md b/README.md\n+preview".to_string(),
file_explorer_selected_index: 0,
focus: DiffFocus::Files,
line_comments: DiffLineComments::default(),
selected_diff_line_index: 0,
preview: DiffPreview::default(),
review_comments: None,
restore: None,
scroll_cache: None,
scroll_offset: 0,
session_id: "missing-session".into(),
};
handle(
&mut app,
TEST_TERMINAL_SIZE,
KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE),
);
assert!(matches!(
app.mode,
AppMode::Diff {
preview: DiffPreview::Unavailable {
reason: DiffPreviewUnavailableReason::LoadFailed(ref error),
..
},
..
} if error == "Session worktree is unavailable"
));
}
#[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 markdown_render_cache = crate::ui::markdown::MarkdownRenderCache::default();
let preview = DiffPreview::default();
let line_comments = DiffLineComments::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(
&DiffScrollLimitInput {
content_area: TEST_TERMINAL_SIZE,
diff: &diff,
diff_layout_cache: &diff_layout_cache,
line_comments: &line_comments,
markdown_render_cache: &markdown_render_cache,
preview: &preview,
selected_index: 0,
},
&mut scroll_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);
}
}